Skip to content
Merged
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
7018b36
Fixed: the instruments panel drawing a removed recording
JakimPL Sep 30, 2026
329bf3b
Corrected: the figures the compression page quotes
JakimPL Sep 30, 2026
b21a5a7
Recorded: what an undo and a late regeneration leave in the Reconstru…
JakimPL Sep 30, 2026
157b8eb
Fixed: the Bitphase rows a note, a triangle and a low pitch write
JakimPL Sep 30, 2026
b043156
Fixed: the FamiTracker rows a note, a triangle and a low pitch write
JakimPL Sep 30, 2026
ad9ec14
Added: a check of what a tracker export plays against the app
JakimPL Sep 30, 2026
ab8e0d5
Fixed: the playback check handing Bitphase relative paths
JakimPL Sep 30, 2026
fd33d7a
Fixed: a note starting from what the voice before it left
JakimPL Sep 30, 2026
5f6e1cb
Fixed: the noise period a Bitphase document plays
JakimPL Sep 30, 2026
bd3afd0
Carried: a transpose-only row into a Bitphase document
JakimPL Sep 30, 2026
1b88230
Carried: a transpose-only row into a FamiTracker module
JakimPL Sep 30, 2026
96ff20a
Reworded: the export notices in plain words
JakimPL Sep 30, 2026
8693478
Added: the rules for messages a user reads
JakimPL Sep 30, 2026
095b808
Fixed: the playback check reading what Bitphase writes to the chip
JakimPL Sep 30, 2026
45b7d5f
Matched: the noise level a row sets to what the trackers play
JakimPL Sep 30, 2026
ef5c611
Fixed: a prompt raised from another prompt's answer
JakimPL Sep 30, 2026
b5f4f1e
Asked: to save the open document before loading a conversion
JakimPL Sep 30, 2026
8997384
Asked: about every unfinished document and job on exit
JakimPL Sep 30, 2026
36c796d
Followed: the open voice across undo, redo and removal
JakimPL Sep 30, 2026
f02f541
Applied: reconstruction edits one step at a time
JakimPL Sep 30, 2026
bd85b7c
Waited: every gesture reading the open sample for its edits
JakimPL Sep 30, 2026
9a09abe
Kept: the waveform faded until the last edit lands
JakimPL Sep 30, 2026
5af4761
Quieted: a save a prompt asks for
JakimPL Sep 30, 2026
1e9b3a3
Kept: the last recording of an edited document
JakimPL Sep 30, 2026
5d67f39
Trimmed: the guide sentences on what the app does by itself
JakimPL Sep 30, 2026
9099a55
Cleared: the file a removed reconstruction was shown on
JakimPL Sep 30, 2026
c7b2843
Removed: redundant description
JakimPL Oct 1, 2026
0254c64
Queued: a modal asked for while another holds the screen
JakimPL Oct 1, 2026
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: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help setup install system-deps build release run calibration clean pre-commit test test-docs benchmarks lint format
.PHONY: help setup install system-deps build release run calibration tracker-playback clean pre-commit test test-docs benchmarks lint format

ifeq ($(OS),Windows_NT)
PYTHON := python
Expand All @@ -25,6 +25,7 @@ help:
@echo $(Q) make test-docs - Run the doctests$(Q)
@echo $(Q) make benchmarks - Run the measured-duration suite$(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 - Play the exported corpus with a Bitphase checkout; reports every tick that differs from the app$(Q)
@echo $(Q) make clean - Remove build artifacts and cache files$(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)
Expand Down Expand Up @@ -52,6 +53,9 @@ run:
calibration:
uv run sampletones calibration

tracker-playback:
uv run sampletones tracker-playback bitphase --checkout $(BITPHASE)

clean:
$(PYTHON) scripts/clean.py

Expand Down
53 changes: 29 additions & 24 deletions docs/concepts/compression.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ through, the same slices the sequencer sounds a row in. On every tick each of th
[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 **49 seconds**. A song of three
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 four bytes' worth even before anything repeats. And a great
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.

Expand Down Expand Up @@ -68,14 +68,17 @@ its first bent tick to its last. The bend plane holds those ticks' steps alone,
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.

Before any phrase, trading two dividers for an index and a bend already pays. On the arrangement of
[section 6](#6-what-it-achieves), which bends most of its notes, the planes and their pitch table take
3.229 bytes a tick against 3.517. Both figures are coded with no plane packing its repeats
([section 2.3](#23-a-value-and-the-ticks-it-lasts)). The index pays because a bend plane has a value only
where a note bends. What it earns beyond that 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)).
Before any phrase, these planes already take less than a plane per register. On the arrangement of
[section 6](#6-what-it-achieves), the planes and their pitch table take 3.229 bytes a tick against 3.517.
Both figures are coded in holds and literals ([section 3](#3-the-token-language)), with no plane packing
its repeats ([section 2.3](#23-a-value-and-the-ticks-it-lasts)). The saving comes from the triangle. Its
value plane names silence, so its control byte leaves the block. On their own, the index and the bend
take 2.5 % more than the dividers. The arrangement bends every note its first pulse channel plays, so
that channel's bend plane has a value on every tick. 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

Expand Down Expand Up @@ -156,7 +159,7 @@ it prices all of a plane's literals in one pass.

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 200 symbols be a single hold. Costs also
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.

Expand Down Expand Up @@ -211,8 +214,8 @@ gain = what the current parse pays for those spans today
− 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 200
identical values looks enormous by length and is worth nothing, because a hold already covers it for one
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
Expand Down Expand Up @@ -246,30 +249,32 @@ could begin and not every symbol of the song.

## 6. What it achieves

Measured over a three-minute arrangement of 10800 ticks, each layer added to the ones above it:
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 |
| planes, coded | 3.517 | 3.13 | 9092 |
| planes with a pitch index and a bend | 2.980 | 3.69 | 10731 |
| 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 most of the notes its pulse channel plays, and its bend plane carries every one of
them. A song played straight leaves its bend planes out of the block. The whole song is 9435 bytes of the
roughly 32000 available, and **37660 ticks is 10.5 minutes at 60 Hz**, against the 49 seconds a record per
tick reaches. Encoding happens once, where the file is written. Decoding costs the console around twenty
instructions per plane per tick, and fewer on a tick a symbol still covers, well inside a video frame.
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 song takes 9435
bytes of the roughly 32000 available, and **37660 ticks is 10.5 minutes at 60 Hz**, against the 48 seconds
a record per tick reaches. Encoding happens once, where the file is written. Decoding the arrangement
costs the console around twenty instructions per plane per tick, and fewer on a tick a symbol still
covers, well inside a video frame.

The format's constants are settled from a corpus of songs. Two results went against expectation.
Splitting the duty cycle out of the control byte into a plane of its own **costs** bytes. On the
arrangement above, encoded at every layer, it costs 16 % where no plane packs, because volume and duty
turn over together, and a split pays two opcodes for what one covers. It costs 56 % where the planes pack
arrangement above, encoded at every layer, it costs 5 % where no plane packs, because volume and duty
turn over together, and a split pays two opcodes for what one covers. It costs 19 % where the planes pack
as the format packs them, because a split plane also 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, and it pays for itself directly as well
table against the fifth. On its own, before any phrase, it costs a little
([section 2.2](#22-pitches-instead-of-dividers)).

An export chooses how far down these layers it goes. Its **Level** names the layers read in order:
Expand Down
44 changes: 42 additions & 2 deletions docs/development/application/dialogs.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# 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, 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.
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

Expand Down Expand Up @@ -42,6 +43,45 @@ where it stands.

Each axis is held at zero at the least, 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 or a prompt raised by a gesture that waited for edits,
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 keeps its place
with the newer request, and one hidden while it waits leaves the line.

`ModalQueue` (`utils/gui/modal_queue.py`) keeps the line, and `GUIWindow.show` is the only way into it, so
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.

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.

## Where it is written

`GUIWindow.dialog_window` (`ui/elements/window.py`) is the only place a dialog's `dpg.window` is opened.
Expand Down
4 changes: 3 additions & 1 deletion docs/development/application/keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ The query resolves the focused item to the field behind it. A `dpg.group` report

### The modal stack

The router holds a LIFO 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.
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.

---

Expand Down
13 changes: 9 additions & 4 deletions docs/development/application/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,15 @@ Muting is monitoring, and principle 5 governs what follows. The project holds ev

## 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, timbre), and an instrument that leaves 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. A FamiTracker instrument follows the same rule with a sequence left out.
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. That is what clearing an envelope in the instruments panel means once the sample is played in a song. A FamiTracker instrument follows the same rule 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 sets its level alone and leaves pitch and timbre where the channel holds them.
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.

A pass through the song begins on the values a channel holds from the start: full volume, no arpeggio offset, the first timbre. Starting the song and looping back to its first row therefore sound the same. Seeking within a running song keeps the values, since the channel has reached them.
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.

## Rendering the song to a file

Expand Down Expand Up @@ -131,7 +135,8 @@ The device holds a release per stream it handed out and invokes it whenever it n
| Error presentation for a source's failures | `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 channel holds between frames | `ChannelState` (`logic/sequencer/playback/synthesizer/state.py`) |
| The values a note starts from and a channel holds between frames | `ChannelPerformance` (`sampletones_core/performance/state.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 | `Groove` and `TickClock` (`sampletones_core/timing/`) |
| Rendering the song to a file, its passes and its progress | `SongRenderService` (`services/render/`) |

Expand Down
Loading
Loading