Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
6 changes: 4 additions & 2 deletions docs/development/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ A new exclusive operation joins by contributing its `is_active` to the authority

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

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

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

### 12. One dispatcher owns the keyboard

Expand Down Expand Up @@ -283,7 +285,7 @@ There are two coordinator kinds:
| `categories/` | `LanguageManager` and the `Page / Panel / TextType / Element` enum hierarchy used as lookup keys |
| `layout/` | Pydantic models loaded from YAML at startup; injected into coordinators and panels as `LayoutConfig` |
| `constants/` | DPG widget tags (`TAG_*`) and tag suffix fragments (`SUF_*`) |
| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, colour, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers |
| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, colour, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol, with the D-Bus desktop-portal client under `utils/file_dialogs/backends/portal/`). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers |
| `viewport.py` | Manages DPG viewport geometry and fullscreen state |

---
Expand Down
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
8 changes: 7 additions & 1 deletion docs/development/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@ See [GPU acceleration](../guide/installation.md#gpu-acceleration) for enabling i

Instruction libraries and reconstructions are serialized with [MessagePack](https://msgpack.org/) (the `msgpack` package). No external compiler or system dependency is required — it is installed automatically with the package.

## File dialogs

Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser`), reached over D-Bus with the pure-Python `jeepney` package on Linux. The portal lists every offered file type in its selector and reports back the one the user picked, which is what lets a save settle its format from the type chosen there. Where no portal answers, `kdialog` and `zenity` take over, and Tk last.

`jeepney` is declared for Linux alone, so the modules that speak to the portal are imported where it is installed: the application probes for it before reaching them, and the root `conftest.py` keeps them out of collection elsewhere, leaving the Linux runs of the suite to cover them.

## Linux (standalone executable)

Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list.

PortAudio is required. Tk backs the file dialogs where `kdialog` and `zenity` are absent, and `make release` requires it so the shipped executable stays self-contained.
PortAudio is required. Tk backs the file dialogs where neither a portal nor a desktop tool answers, and `make release` requires it so the shipped executable stays self-contained.

The executable links against the glibc of the machine that builds it and runs on that version or newer, so a redistributable artifact belongs on the oldest Debian or Ubuntu release being supported.
209 changes: 209 additions & 0 deletions docs/formats/bitphase.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# Bitphase export format

This document is the reference for how _SampleToNES_ writes
[Bitphase](https://github.com/paator/bitphase) files. It describes the two files the
`sampletones_core.formats.bitphase` package produces — the `.btp` document and the
`.json` instrument preset — and the Bitphase capacity limits the exporter respects.
Read it before changing anything under `formats/bitphase/`; the sibling
[FamiTracker export](famitracker.md) document covers the other tracker.

The target is Bitphase's **NES (2A03) chip**: five channels (two squares, triangle,
noise, DPCM), with the DPCM channel always silent by design. Every constant referenced
here has a named counterpart under `sampletones_core/formats/bitphase/specification/`
(grouped by unit: `chip`, `channels`, `instruments`, `patterns`).

Bitphase plays a note by three columns acting together, and that shapes the whole
mapping: an **instrument** supplies the per-tick register values, a **table** supplies
the per-tick pitch movement, and the **note column** supplies the pitch they move
around. A reconstruction's volume and duty envelopes become the instrument, its
arpeggio envelope becomes the table, and its reference pitch becomes the note.

## A. File formats

### A.1 `.btp` — the document

A `.btp` is the document's JSON under gzip — no header and no version field. The
exporter writes it without separator padding and with a fixed gzip timestamp, so
exporting an unchanged document twice yields identical bytes. Written by
`formats/bitphase/btp.py`.

Bitphase's loader reads each field on its own and falls back to a default for any it
misses, so a document that carries every field below loads exactly as it was written.

```
Project { name, author, songs[], loopPointId, patternOrder[], tables[],
patternOrderColors{}, instruments[] }
Song { patterns[], tuningTable[], initialSpeed, chipType, chipVariant,
chipFrequency, interruptFrequency, a4TuningHz, virtualChannelMap{} }
Pattern { id, length, channels[], patternRows[] }
Channel { rows[], label }
Row { note: { name, octave }, effects[], instrument, table, volume }
Table { id, rows[], loop, name }
Instrument { id, chipType, rows[], loop, name }
```

Instruments and tables belong to the **project** rather than to a song, so every song
addresses the same lists. `patternOrder` names the pattern each order position plays,
and `loopPointId` is the order position playback returns to.

**Field names are camelCase.** The Pydantic models under `formats/bitphase/model/`
carry snake_case attributes and serialize through a camelCase alias generator, so the
Python side reads like the rest of the codebase while the file reads like Bitphase's.

### A.2 `.json` — the instrument preset

Bitphase's instruments panel saves and loads a single instrument at runtime through a
file picker. The file holds `{ chipType, name, loop, rows }`, indented the way Bitphase
writes its own, so a preset written here reads like one saved from the tracker. Written
by `formats/bitphase/preset.py`.

A preset carries rows alone, so its pitch movement rides in each row's `toneAdd`
(section C.3) rather than in a table.

## B. The NES instrument

An instrument advances **one row per engine tick** while a note sounds, so a row
carries every register value the channel takes for that tick. From
`formats/bitphase/model/instrument.py`, matching Bitphase's `NesInstrumentRow`:

| Field | Range | Runtime meaning | What the exporter writes |
| --- | --- | --- | --- |
| `pulseWidth` | 0–3 | square duty cycle; on the noise channel, any nonzero value selects the short LFSR | the duty-cycle envelope item (squares), the short/long mode (noise), a flat value (triangle) |
| `volumeOrRate` | 0–15 | the literal channel volume while `envelope` stays off | the volume envelope item |
| `envelope` | bool | reads `volumeOrRate` as a hardware decay rate | `false`, so each item is the volume itself |
| `soundLength` | 0–511 | length counter in ticks; `0` holds the note | `0`, so the volume envelope alone shapes the note |
| `toneAdd` | −4096–4095 | period offset added to the tuning-table period (squares and triangle) | `0` in a document, the pitch contour in a preset |
| `toneAccumulation` | bool | sums `toneAdd` across ticks | `false`, since each item is an absolute offset |
| `retrigger` | bool | restarts the waveform phase this tick | `false`, so the waveform runs continuously |
| `sweep` / `sweepRate` / `sweepShift` | bool / 0–7 / −7–7 | the square channel's hardware sweep | disabled |

**Looping.** Playback returns to the instrument's `loop` row once it runs off the end,
which is the only mode there is. A looping slice therefore sets `loop = 0` so its
envelopes repeat from the start while the note is held; a one-shot sets
`loop = len - 1`, and since the volume envelope ends on a note-off item, the
instrument rests in silence once it has played through. A sample's `loop` flag drives
this, the same flag the FamiTracker exporter reads.

**Equal lengths.** Instrument rows and table rows advance on independent per-tick
counters, so they share a length and a loop point and stay in step for as long as the
note sounds. `equalize_lengths` in `exporters/lengths.py` supplies that shared length —
the same rule the FamiTracker exporter applies, with the item limit left unbounded
here (section D).

## C. Pitch

### C.1 The tuning table

A song carries a 96-entry `tuningTable`, one channel period per note index, built by
`formats/bitphase/tuning.py` as a port of Bitphase's `generate12TETTuningTable`:

```
frequency = a4TuningHz * 2 ^ ((index - 45) / 12)
period = round(chipFrequency / 16 / frequency) clamped to 1..2047
```

Rounding matches JavaScript's `Math.round` (half away from zero on positives), so a
table built here equals the one Bitphase derives from the same settings. The exporter
writes NTSC (1 789 773 Hz) at concert pitch; PAL (1 662 607 Hz) and Dendy
(1 773 448 Hz) are named in `specification/chip.py`.

**A note index is the absolute pitch less 24**, which puts indices 0–95 over pitches
24–119 — the same span the FamiTracker exporter clamps to. A pattern cell stores that
index as a semitone and an octave, which playback resolves back with
`name - 2 + (octave - 1) * 12`.

The triangle channel's period is written from the same table, so a written note sounds
an octave below — the convention SampleToNES and FamiTracker already share.

### C.2 Tables carry the contour

A table holds one semitone offset per tick, and playback adds `rows[position]` to the
channel's note every tick. That is a direct match for a reconstruction's arpeggio
envelope in absolute mode, so the contour crosses over verbatim on the pitched
channels.

A pattern's `table` column names a table by `id + 1`; `0` leaves the attached table
alone and `-1` detaches it.

**Noise** derives its period from the note index rather than from the tuning table:
playback reads `period = 15 - (index mod 16)`. Every period therefore repeats once per
sixteen indices, and the exporter picks a base index far enough below the top of the
table for a whole cycle of offsets to stay in range:

```
base index = 48 + ((15 - initial_period) mod 16) lands in 48..63
table offset = (-arpeggio_step) mod 16 lands in 0..15
```

so `15 - ((base + offset) mod 16)` is the period the reconstruction chose, wrapped into
the sixteen the channel holds.

### C.3 Presets fold the contour into the period

An instrument preset carries no table, so its pitch movement is expressed as the
per-tick `toneAdd` each row applies to the note's own period. The offsets are measured
against the pitch the slice was reconstructed at, under the tuning a freshly created
Bitphase document plays — NTSC at concert pitch. The noise channel takes its period
from the note, so its preset rows hold a flat offset.

## D. What the exporter builds per scope

A `.btp` holds a whole document, so every scope lands in one file; a preset holds one
instrument, so a reconstruction lands as a set of them beside the name the export was
given, one per slice.

| Scope | `.btp` | `.json` preset |
| --- | --- | --- |
| One generator slice | a playable document holding that instrument | one file |
| A whole reconstruction | a playable document holding every slice | one file per slice, beside the chosen name |
| A project | the song, its samples and its arrangement | — |

**Instrument and reconstruction documents are playable.** Each slice becomes an
instrument and the table that carries its contour, and one pattern triggers every slice
at row 0 on the channel it was reconstructed for, so opening the document and pressing
play sounds the reconstruction. The pattern is sized to cover the longest instrument,
and where one instrument outlasts a single pattern the order gains resting positions
until it has played through.

**A project flattens its order.** A SampleToNES order frame points each channel at its
own pattern, where a Bitphase order position names one pattern spanning every channel.
Each frame therefore becomes a pattern of its own carrying that frame's channels side
by side, with `patternOrder = [0..n-1]`. The arrangement crosses over whole; it simply
shares fewer patterns.

Row cells follow from the columns: an instrument command writes the note from
`initial_pitch + transpose`, the instrument number, the table column and the row's
volume; a note-off writes note name `1`; a blank line leaves every column alone.

## E. Bitphase capacity limits

| Quantity | Bitphase limit | Exporter behaviour |
| --- | --- | --- |
| Items per instrument row list | unbounded | writes the envelope whole |
| Rows per table | unbounded | writes the contour whole |
| Instruments | the instrument column holds 2 base-36 digits, so 1–1295 | raises past 1295 |
| Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables |
| Note range | the 96-entry tuning table, pitch 24–119 | clamps to the nearest playable note |
| Pattern length (rows) | 1–256 | clamps the preview pattern; a project keeps `rows_per_pattern` |
| Order positions | unbounded | matches |
| Speed | 1–255 | written verbatim from settings |
| DPCM channel | present | emitted empty |

Tables and instruments are numbered together — each slice takes one of each — so the
table column is what a wide document reaches first: 35 slices fit, and the exporter
raises rather than writing a document whose later voices cannot be named.

## F. What does not cross over

Three things the SampleToNES model holds have no counterpart in a Bitphase document,
and the exporter leaves them behind:

- **`ProjectInfo.comment`** — a Bitphase project carries a name and an author only.
- **`ProjectSettings.tempo`** — Bitphase's engine is speed-only, so `initialSpeed`
carries `speed` and the tempo is left to the tick rate.
- **A volume column of `0`** — Bitphase reads it as "leave the volume alone", so a row
that asks for silence through the volume column alone reaches playback unchanged.

`interruptFrequency` carries the reconstruction's own tick rate. Bitphase's settings
panel offers 50 and 60 Hz, and its loader and timeline accept any value, so a rate
outside that pair plays correctly while leaving that one selector unmatched.
Loading