Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion docs/development/guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ These rules govern the Python in this repository. They complement
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 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. 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.
Expand Down Expand Up @@ -86,6 +86,7 @@ These rules govern the Python in this repository. They complement
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. 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. Do not assert default values of configurations, layouts, settings, and similar. Defaults are not contracts, and pinning them overconstrains the tests. Test behavior instead: validation bounds, serialization round-trips, and invariants. The exception is when values must match by contract rather than equal a chosen constant — e.g. project metadata at creation or after a save/load round-trip should be asserted to match, never hardcoded to a version string.
Expand Down
18 changes: 18 additions & 0 deletions docs/development/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,22 @@ sequencer distinguishes a history restore from a document transition. And the mu
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`
Expand Down Expand Up @@ -231,6 +247,8 @@ terminating would reclaim.
| 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` |
Expand Down
18 changes: 13 additions & 5 deletions docs/formats/bitphase.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ carries every register value the channel takes for that tick. From
| 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 |
| `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 |
Expand All @@ -79,10 +79,18 @@ carries every register value the channel takes for that tick. From

**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.
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
Expand Down
80 changes: 70 additions & 10 deletions docs/formats/famitracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,19 +146,32 @@ sequence, so its envelopes repeat from the start while the note is held; a one-s
instrument leaves every loop point at `-1`. A sample's `loop` flag drives this when
the sample is exported into a module.

**Equal lengths.** FamiTracker advances each sequence on its own per-tick counter, so
every populated sequence of an instrument carries the same item count and the
dimensions stay in step. The volume envelope arrives one item longer than the others,
carrying a trailing zero that releases the note. A looping instrument therefore keeps
the shortest length, dropping that trailing item so the loop sustains; a one-shot
keeps the longest, each shorter dimension holding its final value through the release
tick. That shared 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.
**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
Expand Down Expand Up @@ -202,3 +215,50 @@ for order slots the song leaves unset; a channel that already fills indices up t
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.

## D. 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.

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 |
| --- | --- | --- |
| instrument list entry | 2 | instrument |
| sequence-enable bitmask | 1 | instrument |
| sequence pointer, per populated sequence | 2 | instrument |
| 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.
22 changes: 19 additions & 3 deletions docs/formats/reconstructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,31 @@ A `.stn` file holds:
* **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 enabled channel (`pulse1`, `pulse2`, `triangle`, `noise`);
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;
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)).
[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.

## Detached reconstructions

Expand Down
11 changes: 8 additions & 3 deletions docs/guide/interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ 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. **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).
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

Expand Down
7 changes: 5 additions & 2 deletions docs/guide/sequencer.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ 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. Removing a sample that patterns still use asks **Remove sample** first,
because it clears every row that references it.
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.

## Writing a pattern

Expand Down
Loading
Loading