Skip to content
Merged

Tags #47

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e7c9124
Implemented: shared tracker backend
JakimPL Jul 31, 2026
6068627
Added: Bitphase support
JakimPL Jul 31, 2026
67158f1
Wired: Bitphase export
JakimPL Aug 1, 2026
2e0048c
Extended: Bitphase UI elements
JakimPL Aug 1, 2026
84afaae
Fixed: missing initial pitch
JakimPL Aug 1, 2026
34990f8
Updated: docs and tests
JakimPL Aug 1, 2026
ceb2492
General improvements
JakimPL Aug 1, 2026
05c1006
Added: remaining tests and docs
JakimPL Aug 3, 2026
0ff4507
Updated: documentation
JakimPL Aug 3, 2026
c5b3efa
Unified: generator slice naming across export paths
JakimPL Aug 3, 2026
7ac3d20
Changed: instrument export destinations to files
JakimPL Aug 3, 2026
5b44c96
Changed: instrument export destinations to files named after the batch
JakimPL Aug 3, 2026
079cb7a
Added: desktop portal file dialogs reporting the chosen file type
JakimPL Aug 3, 2026
a416d95
Simplified: instrument export around the dialog's file-type selector
JakimPL Aug 3, 2026
b258e98
Bounded: the portal dialog wait
JakimPL Aug 3, 2026
3cf6c79
Updated: documentation
JakimPL Aug 4, 2026
bdb1ffa
Moved: concrete file-dialog backends into a backends subpackage
JakimPL Aug 4, 2026
7d39248
Folded: file-dialog helpers into their backends and shared the comman…
JakimPL Aug 4, 2026
28ec5c2
Split: the portal client into variant, response, client, and backend
JakimPL Aug 4, 2026
d8f3620
Fixed: portal dialogs opening behind the application window
JakimPL Aug 4, 2026
e34d4b7
Merge pull request #46 from JakimPL/bitphase
JakimPL Aug 4, 2026
614a544
Added: centralized tag composition
JakimPL Aug 6, 2026
88e626d
Added: string text keys with grammar validation
JakimPL Aug 6, 2026
617b756
Added: AST analysis layer
JakimPL Aug 6, 2026
1de7b20
Added: bidirectional language-key and unused-tag checks
JakimPL Aug 6, 2026
5417837
Transformed: language manager entries into inline ones
JakimPL Aug 6, 2026
02ae1e2
Documented: tag composition and text-key contracts
JakimPL Aug 6, 2026
534b966
Fixed: test path
JakimPL Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 2 additions & 2 deletions .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
- name: Verify tag matches project version
if: startsWith(github.ref, 'refs/tags/v')
run: |
python3 scripts/ci/check_version_tag.py \
python3 scripts/ci/checks/version_tag.py \
--tag "$GITHUB_REF_NAME" \
--project-version "$(uv version --short)"

Expand Down Expand Up @@ -137,7 +137,7 @@ jobs:

- name: Check the bundle runs and carries its notices
shell: bash
run: python scripts/ci/check_bundle.py bin/sampletones
run: python scripts/ci/checks/bundle.py bin/sampletones

- name: Zip the bundle
shell: bash
Expand Down
20 changes: 18 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,36 @@ repos:
hooks:
- id: import-boundary
name: import boundary
entry: uv run scripts/check_import_boundary.py --all
entry: uv run scripts/checks/import_boundary.py --all
language: system
types: [python]
pass_filenames: false
verbose: true

- id: unused-tags
name: unused tags
entry: uv run scripts/checks/unused_tags.py
language: system
types: [python]
pass_filenames: false
verbose: true

- id: tag-names
name: tag names
entry: uv run scripts/check_tag_names.py
entry: uv run scripts/checks/tag_names.py
language: system
types: [python]
files: ^src/sampletones_application/tags/
verbose: true

- id: language-keys
name: language keys
entry: uv run scripts/checks/language_keys.py
language: system
files: (\.py|^src/sampletones_config/lang/.*\.yaml)$
pass_filenames: false
verbose: true

- id: mypy
name: mypy
entry: uv run mypy
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# SampleToNES

## v0.3.1 [2026-07-31]

* Added support to [Bitphase](https://github.com/paator/bitphase).
* Fixed arpeggio editing shifting a sample's pitch permanently.
* Bumped the reconstruction data-version to `2.1`.

## v0.3.0 [2026-07-31]

* Added a _Sequencer_ view with FamiTracker-style patterns.
Expand Down
14 changes: 12 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.PHONY: help setup install build release system-deps run clean pre-commit test \
ftm-samples check-import-boundary calibration lint pylint mypy format
ftm-samples check-import-boundary check-tag-names check-unused-tags \
check-language-keys calibration lint pylint mypy format

ifeq ($(OS),Windows_NT)
ifeq ($(MSYSTEM),)
Expand Down Expand Up @@ -106,7 +107,16 @@ ftm-samples:
uv run python -m pytest tests/integration/famitracker

check-import-boundary:
uv run scripts/check_import_boundary.py --all
uv run scripts/checks/import_boundary.py --all

check-tag-names:
uv run scripts/checks/tag_names.py --all

check-unused-tags:
uv run scripts/checks/unused_tags.py

check-language-keys:
uv run scripts/checks/language_keys.py

calibration:
uv run scripts/calibration.py --all
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ It supports:
* `pulse2`
* `triangle`
* `noise`
* exporting reconstructed audio as FamiTracker `.fti` instruments or as `.wav`
* exporting reconstructed audio as FamiTracker `.fti` instruments, Bitphase `.json` instrument presets, or `.wav`

## Installation

Expand Down
38 changes: 38 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import importlib.util
from pathlib import Path
from typing import Final, Optional, Tuple

JEEPNEY_MODULE: Final[str] = "jeepney"

PORTAL_PATHS: Final[Tuple[str, ...]] = (
"src/sampletones_application/utils/file_dialogs/backends/portal",
"tests/unit/sampletones_application/utils/file_dialogs/backends/portal",
"tests/unit/sampletones_application/utils/file_dialogs/test_selection.py",
)

PORTAL_LIBRARY_INSTALLED: Final[bool] = importlib.util.find_spec(JEEPNEY_MODULE) is not None


def pytest_ignore_collect(collection_path: Path) -> Optional[bool]:
"""
Keeps collection to the modules the running platform imports.

``jeepney`` is declared for Linux alone, so what speaks to the desktop portal is collected
where that library is installed. The behaviour those modules describe belongs to the Linux
desktop, and the Linux runs of the suite cover it.

Args:
collection_path: The file or directory pytest is about to look into.

Returns:
Optional[bool]: ``True`` for a path that stays out of collection, ``None`` to leave the
choice with pytest.
"""
if PORTAL_LIBRARY_INSTALLED:
return None

root = Path(__file__).parent
if any(collection_path.is_relative_to(root / path) for path in PORTAL_PATHS):
return True

return None
62 changes: 47 additions & 15 deletions docs/development/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,35 @@ Services execute long-running work on background threads. Their results are post

### 8. All display text comes from `LanguageManager`

Every user-visible string is looked up via `LanguageManager[Page, Panel, TextType, Element]`, where `Element` is a `StrEnum` member defined under `categories/elements/`. This makes the text system the single source of truth and enables future localisation. Log messages are developer-facing and exempt.
Every user-visible string is looked up on `LanguageManager` by the key the language file spells:

### 9. `constants/` holds only DPG identifiers
```
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 one of the element enums under `categories/elements/`. `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.

The `constants/` package contains only `TAG_*` values (DPG widget string identifiers) and `SUF_*` values (suffix fragments used to compose tags programmatically). Dimensions, colours, timings, and display strings live in YAML configuration loaded at startup (`layout/`).
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:

Tag naming convention:
```python
language_manager["global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name"]
```
TAG_<MODULE>_<WIDGET_TYPE>[_<DETAIL>]

### 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
```
`MODULE` is the feature area (`GLOBAL`, `MAIN`, `RECONSTRUCTIONS`, `SEQUENCER`, `INSTRUCTIONS`, `PLAYER`, `SETTINGS`); `WIDGET_TYPE` is the DPG element kind (`WINDOW`, `PANEL`, `TREE`, `TABLE`, `BUTTON`, `INPUT`, `TABS`, `TAB`, `THEME`, `FONT`, `MENU`); `DETAIL` disambiguates multiple instances of the same type in the same module.

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.

### 10. Exclusive operations expose a lifecycle-accurate active state

Expand All @@ -102,7 +120,9 @@ A new exclusive operation joins by contributing its `is_active` to the authority

Where behaviour depends on the operating system, the desktop environment, or an external command-line tool, that variation is expressed as a `Protocol` with one implementation per target, chosen by a runtime factory — never as platform branches scattered through the callers. The factory probes availability (`shutil.which`) and environment (`System.current()`, `XDG_CURRENT_DESKTOP`) and returns the implementation that fits; callers depend only on the Protocol and read identically on every platform.

`utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol with `kdialog`, `zenity`, and `tkinter` implementations, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — `kdialog` activates the supplied filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries the configured extension, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory.
`utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol in `protocol.py`, with desktop-portal, `kdialog`, `zenity`, and `tkinter` implementations under `backends/`, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — the portal lists every offered type in its selector, reports the one the user picked, and is told which window a dialog belongs to, since the desktop draws it in another process, `kdialog` activates a single filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries one of the offered extensions, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory.

Ordering the implementations is part of the factory's job: where several are available, the one that expresses the most wins. A save offering several file types is answered by the portal because it alone reports which type was chosen, so an export names its format in the type selector; a backend answering with a name alone leaves the extension to be read from the name, and the API layer settles it either way.

### 12. One dispatcher owns the keyboard

Expand Down Expand Up @@ -132,7 +152,17 @@ The router is constructed at the composition root and injected into every consum

Two mechanisms keep the codebase aligned with this document.

**Import-expressible contracts are enforced by script.** `scripts/check_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 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.

**The identifier vocabularies are enforced the same way.** Three more scripts under `scripts/checks/` run whole-tree as pre-commit hooks, each also available as a `make check-*` target:

| Hook | Script | What it holds |
|------|--------|---------------|
| `language-keys` | `language_keys.py` | Code and `en.yaml` against each other, in both directions: a literal key names an entry, every entry is reached by some lookup, and a lookup states values the check can read (principle 8) |
| `tag-names` | `tag_names.py` | A tag constant's name against the tag it composes (principle 9) |
| `unused-tags` | `unused_tags.py` | Every `TAG_*`/`SUF_*`/`PRE_*` the `tags/` package declares against the reads of it across `src/`, `tests/`, and `scripts/`, where an import alone stands at no reads |

All three 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. 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.

Expand Down Expand Up @@ -165,7 +195,7 @@ Two mechanisms keep the codebase aligned with this document.
| `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/`, `constants/`, `layout/`, `sampletones_core` types, `sampletones_shared`.
**May import:** `view_model/`, `utils/`, `categories/`, `tags/`, `layout/`, `sampletones_core` types, `sampletones_shared`.
**Must not import:** `coordinators/`, `logic/`, `services/`, `config/`, `application.py`, `shell.py`, `utils/gui/dialogs` (`DialogsRenderer` is coordinator territory).

---
Expand Down Expand Up @@ -280,10 +310,10 @@ There are two coordinator kinds:
| Package | Purpose |
|---------|---------|
| `config/` | `ConfigManager` (domain generation config), `SessionManager` (runtime session: last paths, audio device, window geometry). Presentation-free: it records load outcomes (`ConfigLoadOutcome`) as domain data for `ConfigCoordinator` to present. Must not import the visual packages, `coordinators/`, or `application.py` |
| `categories/` | `LanguageManager` and the `Page / Panel / TextType / Element` enum hierarchy used as lookup keys |
| `categories/` | `LanguageManager`, the `Page / Panel / TextType / Widget` enum hierarchy and the element enums that name lookup keys, and the key grammar under `categories/key/` |
| `layout/` | Pydantic models loaded from YAML at startup; injected into coordinators and panels as `LayoutConfig` |
| `constants/` | DPG widget tags (`TAG_*`) and tag suffix fragments (`SUF_*`) |
| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, colour, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers |
| `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 |

---
Expand Down Expand Up @@ -410,9 +440,9 @@ sampletones_application/
├── 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
├── categories/ ← LanguageManager + lookup enums, with the key grammar under key/
├── layout/ ← LayoutConfig (Pydantic) + YAML loaders
├── constants/ ← TAG_* and SUF_* identifiers only
├── tags/ ← TAG_*, SUF_*, PRE_* identifiers and compose_tag only
└── utils/ ← dpg-free helpers; dpg-bound helpers under utils/gui/
```

Expand All @@ -429,8 +459,10 @@ sampletones_application/
| Manager class | `<Domain>Manager` | `ReconstructionManager` |
| Controller class | `<Domain>Controller` | `ProjectController` |
| Service class | `<Domain>Service` | `ConversionService` |
| DPG widget tag | `TAG_<MODULE>_<WIDGET>[_<DETAIL>]` | `TAG_MAIN_PANEL_CONFIG` |
| DPG widget tag | `TAG_` + the composed tag, upper-cased | `TAG_MAIN_CONFIG_TABLE_CONFIG_ROW` (`main.config.table.config_row`) |
| Tag suffix | `SUF_<ROLE>` | `SUF_PANEL_LEFT` |
| Tag prefix | `PRE_<ROLE>` | `PRE_RECONSTRUCTION_GENERATOR` |
| Text key | `page.panel.text_type.element` | `global.dialog.label.ok` |
| Panel callback hook | `on_<event>` attribute | `on_convert_requested` |
| Panel state hook | `can_<action>` or `<action>_<subject>` attribute | `can_add_to_sequencer`, `replace_in_sequencer_label` |
| Logic callback | `on_<event>` attribute | `on_view_changed` |
5 changes: 5 additions & 0 deletions docs/development/bugs-and-todos.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
### Navigation

* Interface scale
* VSync/frame rate options
* Tree navigation using keys
* Waveform LOD for zooming
* Keybindings options
* Tracker cell shortcuts
* Drag and drop
* Multiple Reconstruction views
* Playing a fragment by clicking on a waveform
* Transpose/note pitch display duality

### Tracker

Expand All @@ -25,13 +28,15 @@

* Theme selector and palette management
* In-application guide/tutorial
* Language selector

### Technical

* API documentation
* Code documentation (docstrings)
* Backward compatibility: library/reconstruction upgrade scheme
* Respecting FamiTracker limitations
* Carrying the project comment and tempo into a Bitphase document, once the format holds them
* Per-tab undo routing

## Bugs
Expand Down
Loading