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
4 changes: 1 addition & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
## v0.3.2

* Added NSF player and export.
* Added stems conversion: mix several recordings into one reconstruction.
* Matched each stem against its own recording, so a stem plays what was recorded on it.
* Added a per-source channel cap.
* Added stems conversion: to mix several recordings into one reconstruction.
* Bumped the reconstruction data-version to `2.2` with backward compatibility for `2.1`.

## v0.3.1 [2026-08-18]
Expand Down
6 changes: 6 additions & 0 deletions docs/concepts/compression.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,12 @@ volume and duty turn over together and a split pays two opcodes for what one cov
and the pitch index earns its place twice, 5 % directly and a further 12 % through the
transposition it makes possible.

An export chooses how far down these layers it goes. The **Level** it is written at 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, and *Full search* is
the whole codec. A lighter level finishes sooner and takes more room, and every one of them
plays the same song.

## 7. Limitations

- **The search reads a dense plane only so far.** A reconstruction whose planes turn over
Expand Down
5 changes: 3 additions & 2 deletions docs/development/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ They read the source as an AST through the source layer in `sampletones_tools/ch

**Naming convention:** `<Feature><Component>ViewModel`, e.g. `ConverterViewModel`, `SequencerTrackerViewModel`.

**May import:** `constants/`, `sampletones_core` types, `sampletones_shared`, Python standard library.
**May import:** `constants/`, `sampletones_core` and `sampletones_player` types, `sampletones_shared`, Python standard library.
**Must not import:** `ui/`, `coordinators/`, `logic/`, `services/`, `config/`.

---
Expand Down Expand Up @@ -218,7 +218,7 @@ They read the source as an AST through the source layer in `sampletones_tools/ch
- 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.
**May import:** `sampletones_core`, `sampletones_player`, `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.

---
Expand Down Expand Up @@ -257,6 +257,7 @@ There are two coordinator kinds:
- 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. A wrapper is sanctioned where a contract requires an intent-level guard — a busy-authority start-time guard (principle 10) wrapping an operation's entry point — and that guard is the whole of what the wrapper holds. 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, so a surface names no format and a format gains a setup in one place.

**May import:** `ui/`, `view_model/`, `logic/`, `services/`, `utils/`, `categories/`, `layout/`, `config/`.
**Must not import:** `application.py`, `shell.py`.
Expand Down
1 change: 1 addition & 0 deletions docs/development/guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ These rules govern the Python in this repository. They complement
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. A document change 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 that document names. What a branch leaves behind is therefore the current contract, the recorded distance from it, or both.
1. Changelog is only for changes that are meaningful to users. In particular, refactors that introduce no new features must not be present in the changelog.
1. Use American English, in prose and identifiers alike. A name someone else owns keeps the spelling they gave it: `MatchRule.serialise()` is jeepney's, `CancelledError` is the standard library's.

## Guide
Expand Down
2 changes: 1 addition & 1 deletion docs/development/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ them.
| `builder.py` | The song a reconstruction or an export request plays as, its instructions encoded, its planes compressed and its rate scheduled | `song.py`, `registers/`, `clock/`, `compression/` |
| `nsf/` | The song block, the header and the `.nsf` file the console loads | `song.py`, `specification/`, `compression/`, `driver/` |
| `driver/` | The assembled 6502 driver and the addresses its build reports | `specification/` |
| `export.py` | `NSFBackend` — the export seam answered in `.nsf` files, holding the driver every one of them carries and saying which stage a run is in | `builder.py`, `nsf/`, `driver/`, `compression/` |
| `export/` | `NSFBackend` — the export seam answered in `.nsf` files, writing each request as the program its source states or the one a user chose (`NSFProgram`: channels, repeat, compression scheme and header text), holding the driver every file carries and saying which stage a run is in | `builder.py`, `song.py`, `nsf/`, `driver/`, `compression/` |

### The toolchain and the oracle live with the tools

Expand Down
8 changes: 8 additions & 0 deletions docs/development/player.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ from: `song_from_reconstruction` sounds a reconstruction's own instructions,
whole arrangement out row by row through the same walk the sequencer sounds a song with.
The last of those is the one 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, and
the builders take the first three explicitly. `NSFProgram.for_project` and
`NSFProgram.for_sample` state what an export writes when nobody chose otherwise, and the
export dialog opens on the same values. `NSFBackend` answers the export seam either with those
stated programs or, through `choosing`, with one a user settled, so the service that runs an
export stays free of anything the format decides.

A project carries no tuning of its own — each sample was reconstructed against one — so the
samples state it by agreeing on it, and a project whose samples disagree is refused rather
than sounded half in tune.
Expand Down
12 changes: 11 additions & 1 deletion docs/formats/nsf.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ change made in one file and forgotten in the other is reported by name.

The header is NSF version 1: the magic `NESM\x1a`, one song, the load, init and play
addresses, three 32-byte text fields, and the NTSC play period. The text fields carry the
name of what was exported, its author and the copyright. Written by `nsf/header.py`.
title, the artist and the copyright an export states. Each is UTF-8 ending in a NUL, so it
holds `STRING_TEXT_SIZE` bytes of text, cut on a character boundary; `nsf/information.py`
states that cut, and `nsf/header.py` writes the header.

The driver's entry points lead its image as a pair of jumps, so `init` answers at the load
address and `play` three bytes later whatever the driver's own length. That is what lets
Expand Down Expand Up @@ -140,6 +142,10 @@ begins a token on every plane, and that token names its values outright rather t
leaning on the value the plane had reached. Coming round is then a matter of pointing each
plane at the byte the header states 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 at all, in which case the header states `$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:
Expand All @@ -161,6 +167,10 @@ The planes are written in this order, and each group belongs to one channel:
Splitting a channel's registers apart is what 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 holds its
silent values from the first tick to the last, which a hold covers in a few bytes, so the
driver reads the same layout whichever channels a song sounds.

**The noise channel reads no bend.** It selects one of sixteen fixed periods, so there is
no finer grid for a bend to reach, and the plane it would hold is left out of the block.

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ The name you type also names the instrument in the tracker:
| Export | The name you type | What the app saves |
| --- | --- | --- |
| **Instruments** panel ▸ **Export instrument...** | the file | one file, and the instrument inside it has the same name |
| **Reconstruction ▸ Export instruments** | the set | one file per channel, each named `<name> (channel)`. For `.nsf`, one file with the name you typed |
| **Reconstruction ▸ Export instruments** | the set | one file per channel, each named `<name> (channel)`. For `.nsf`, one file, and its title is set in the **Export NSF program** window |
| **File ▸ Export** | the file | one file with the whole song |

For example, exporting a reconstruction named `Kick` to FamiTracker instruments saves
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/reconstruction.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ 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...** writes a single `.nsf` file that plays the whole reconstruction on a NES.
- **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 works as it does [in the sequencer](sequencer.md#exporting-an-nsf-program). A reconstruction has no order frames, so **Repeat** has no **From a frame**. It has no samples either, so **Level** has no **Samples**. You can only select the channels the reconstruction uses.
- **Export to WAV...** renders the audio using the channel and stem checkboxes you have set.

**Export instrument...** in the **Instruments** panel writes only the channel you are looking at, in whichever format you choose in the save dialog. See [where your files live](files.md#naming-exported-files).
Expand Down
15 changes: 15 additions & 0 deletions docs/guide/sequencer.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,21 @@ To export the song:
directly. An NSF program has room for 32 KB, so the app tells you when a song is too long. See [NSF
export](../formats/nsf.md) and [song compression](../concepts/compression.md).

## Exporting an NSF program

**File ▸ Export ▸ NSF program...** opens the **Export NSF program** window. Click **Export** without
changing anything to save the whole song, repeating from the start.

| Setting | What it does |
|---------|--------------|
| **Title**, **Artist**, **Copyright** | The text an NSF player shows. Each one holds 31 bytes |
| **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**, numbered as in the order list |
| **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 |
| **File** | Where the file is saved. **Browse...** opens the save dialog |

**Export** closes the window and shows the export's progress.

## Rendering to audio

**File ▸ Render song...** (`Ctrl+Shift+E`) saves the whole song as an audio file that any player
Expand Down
4 changes: 2 additions & 2 deletions src/sampletones/self_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,9 @@ def _check_export_backends() -> str:
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 build_export_backends
from sampletones_application.exports import ExportBackends

return ", ".join(sorted(build_export_backends()))
return ", ".join(sorted(ExportBackends.build().by_format))


def _check_file_dialog_backend() -> str:
Expand Down
51 changes: 43 additions & 8 deletions src/sampletones_application/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
InstrumentExportCoordinator,
SongExportCoordinator,
)
from sampletones_application.coordinators.export.nsf import NSFExportCoordinator
from sampletones_application.coordinators.export.setup import ExportSetup
from sampletones_application.coordinators.keybindings import KeybindingsCoordinator
from sampletones_application.coordinators.original_audio import OriginalAudioLocator
from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
Expand All @@ -38,10 +40,11 @@
ReconstructionTabCoordinator,
)
from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator
from sampletones_application.exports import build_export_backends
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 (
Expand Down Expand Up @@ -117,6 +120,7 @@
)
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,
)
Expand Down Expand Up @@ -172,6 +176,7 @@
from sampletones_core.project.voices.voice import samples
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures.tree import FileSystemNode
from sampletones_player.export.backend import NSFBackend
from sampletones_shared.application import (
SAMPLETONES_AUTHOR,
SAMPLETONES_GROUP,
Expand Down Expand Up @@ -263,7 +268,9 @@ def __init__(
self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority)
self.retune_service.subscribe(self._on_retune_result)

self.export_backends: Dict[ExportFormat, ExportBackend] = build_export_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)
Expand Down Expand Up @@ -340,6 +347,15 @@ def __init__(
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,
Expand Down Expand Up @@ -398,12 +414,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,
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,
Expand Down Expand Up @@ -447,6 +480,7 @@ def __init__(
browser_manager=self.browser_manager,
export_service=self.export_service,
export_backends=self.export_backends,
format_setups=self._format_setups,
on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation,
on_change_audio_state=self._update_menu,
on_favorite_changed=self._repaint_reconstruction_favorites,
Expand Down Expand Up @@ -568,7 +602,7 @@ 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(
Expand Down Expand Up @@ -975,6 +1009,7 @@ 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()
)

Expand All @@ -1001,12 +1036,12 @@ def _refresh_busy_state(self) -> None:
self._instructions_tab.refresh_generate_button()
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()
Expand Down
1 change: 1 addition & 0 deletions src/sampletones_application/categories/hierarchy.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,4 @@ class Panel(StrEnum):
PROPERTIES = auto()
RENDER = auto()
EXPORT = auto()
NSF = auto()
Loading
Loading