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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* Theme selector
* Keybinding settings
* Bumped the reconstruction data-version to `2.1`.
* Improved Sequencer module playback.
* Added song export to WAV/MP3.

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

Expand Down
16 changes: 10 additions & 6 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,16 @@ are not used in any SampleToNES component name.

Every dependency is installed separately by `pip`/`uv` from PyPI and imported at runtime.

Most dependencies are permissively licensed (MIT, BSD, Apache-2.0, ISC). Two are under the
GNU Lesser General Public License — [Pebble](https://pypi.org/project/Pebble/) (LGPL-3.0,
a direct dependency) and [soxr](https://pypi.org/project/soxr/) (LGPL-2.1-or-later, a
transitive dependency of `librosa`) — and two, `certifi` and `tqdm`, are under MPL-2.0.

All four are used as unmodified, separately installed libraries loaded dynamically at
Most dependencies are permissively licensed (MIT, BSD, Apache-2.0, ISC). Three carry code
under the GNU Lesser General Public License — [Pebble](https://pypi.org/project/Pebble/)
(LGPL-3.0, a direct dependency), [soxr](https://pypi.org/project/soxr/)
(LGPL-2.1-or-later, a transitive dependency of `librosa`), and
[soundfile](https://pypi.org/project/soundfile/) (BSD-3-Clause itself, a direct
dependency, whose wheel carries the libsndfile shared library under LGPL-2.1-or-later with
LAME and mpg123 statically linked into it) — and two, `certifi` and `tqdm`, are under
MPL-2.0.

All of them are used as unmodified, separately installed libraries loaded dynamically at
import time. No LGPL- or MPL-licensed code is copied into the wheel or the sdist, so the
MIT License applies to the PyPI package without further obligation.

Expand Down
21 changes: 21 additions & 0 deletions docs/development/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ Playback goes through PortAudio, reached with the `pyaudio` package. PyPI carrie

Compiling on macOS also depends on the interpreter's architecture. The python.org installer ships a universal2 build, which compiles extensions for both Apple Silicon and Intel, while Homebrew's `libportaudio` carries the machine's own architecture. Pinning `ARCHFLAGS` to `uname -m` settles it on the native one: `make setup` sets it directly, and the CI workflows take it from `scripts/macos/build/build_env.sh`, which reports it as a `KEY=VALUE` line alongside the PortAudio prefix for a Homebrew installed outside its usual place.

## Audio rendering

Audio files are written with libsndfile, reached with the `soundfile` package. Its wheels carry a
prebuilt libsndfile 1.2.2 for every supported platform, so the encoders come with the package and
need nothing installed alongside them.

Which formats an installation writes is asked of the library at runtime, because libsndfile is built
with a codec set that varies by platform and packaging — the MP3 encoder in particular arrived in
1.2.0 and is present where it was compiled in. The chooser offers the formats the library reports,
so what a user is shown describes the machine it is running on.

| Format | Sample rates | Quality |
| --- | --- | --- |
| WAV | 8000, 16000, 22050, 44100, 48000, 96000, 192000 Hz | 8, 16, 24 or 32-bit PCM, or 32-bit float |
| MP3 | 8000, 16000, 22050, 44100, 48000 Hz | a bitrate from the ladder its MPEG version defines |

The bitrates on offer narrow with the sample rate: up to 320 kbps at 44100 and 48000 Hz, 160 kbps at
16000 and 22050 Hz, and 64 kbps at 8000 Hz. libsndfile takes MP3 quality as a compression level
between 0 and 1 and turns it into a rung on that ladder, so a bitrate is reached through the level
its rate maps it to, measured per rate and held in `sampletones_core/audio/writers/bitrate.py`.

## 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.
Expand Down
53 changes: 50 additions & 3 deletions docs/development/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,17 @@ a control over what is heard. The contracts here bind every tab and every player
same behaviour.
5. **Listening choices stay out of the document.** What the user chooses to hear is session state;
what the project holds is the whole song. Saving, export, rendering, and history read the
document, so each of them works on the full song whatever the user is listening to.
document, so each of them works on the full song whatever the user is listening to. A render
reads the document as it stood when it was asked for: every channel sounding, at unity gain,
played through once.
6. **Live state is pulled while sound is produced.** A player reads the settings that shape its
sound as it renders, so a change is heard as the render-ahead buffer drains. This is what lets a
listening control take effect inside the sound already playing.
7. **A row's duration belongs to the song, not to the player.** How long a row lasts follows from
the project's tempo and metre together with the row's place in the pattern, so it is a function
of position: the same row lasts the same time however playback reached it, and a module exported
from the song can state the same figures.
from the song can state the same figures. The integer tick counts the groove places *are* the
tempo, so a render realises them exactly at every rate it offers.

## Two kinds of sound

Expand Down Expand Up @@ -159,6 +162,42 @@ 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.

## Rendering the song to a file

A render writes the whole song to an audio file through the kernel that plays it. `RowSynthesizer`
serves both: the player drives it to feed the device, the render drives it to feed a file writer.
The synthesis is therefore written once, and the file and the playback agree on what the song
sounds like by construction.

Two things differ between them, and each is stated by whoever asks for the audio. The **document**
is a seam: a kernel reads its project through `ProjectSource`, which the live controller satisfies
for playback and a frozen `ProjectSnapshot` satisfies for a render — so the player follows every
edit as the buffer drains (principle 6), while a render describes one state of the document however
the project moves on. The **rate** is the consumer's: the device for playback, the chosen output
format for a render. The kernel rebuilds its generators and its tick clock when either moves, so a
file is written at the rate its engine ran at.

A rate is therefore asked for once there is audio to take it, which is the first row a kernel
renders: a device has been chosen by the time playback starts, and a format by the time a render
does. A session on a machine offering no output device opens on that rule, and everything that
writes rather than sounds — editing, exporting a module, rendering to a file — works on it.

The song's exact length follows from the timing model before a sample is rendered: the order's
length in rows gives the ticks, the tick clock gives the samples those ticks span. That figure is
what the progress bar counts against and what a finished file measures.

Rendering is an exclusive operation (architecture principle 10). It occupies the application from
the moment its dialog opens until that dialog closes, and it joins the same busy authority as
conversion and library generation, so each of the three holds the others off and every surface
offering one reads a single answer.

The write itself takes one pass, or two where the user asks for a normalised peak: the first pass
spills raw samples and discovers the peak, the second reads them back and encodes at the scale that
peak sets. Each pass names itself, so the bar crosses one axis — samples — twice, holding a single
unit across both. A cancel is honoured between rows and between encoded blocks, and a render that
is stopped or fails clears the destination and the spill, so a result names a path where a finished
file stands.

## Teardown

The device is torn down once every source holding a stream has released it. A source that streams to
Expand Down Expand Up @@ -191,9 +230,17 @@ terminating would reclaim.
| The reach the sequencer view follows the playhead at | `FollowMode` (`constants/playback.py`), held by `SongPlayerLogic` (`logic/sequencer/playback/song_player.py`) |
| 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.py`) |
| Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) |
| 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` |
| The song's render-ahead buffer | `services/song_player/` |
| The document a kernel reads, live or captured | `ProjectSource` / `ProjectSnapshot` (`logic/shared/project_source.py`) |
| The ticks the order lasts and the samples they span | `SongLength` (`logic/sequencer/playback/synthesizer/length.py`) |
| Rendering the song to a file, its passes and its progress | `SongRenderService` (`services/render/`) |
| Where a rendered file's samples go, normalised or direct | `RenderSink` (`services/render/sink.py`) |
| The choices a render is made under, and the phase it is in | `SongRenderLogic` (`logic/render/`) |
| The formats a file may be written in, and what each accepts | `sampletones_core/audio/writers/` |

The sequencer song is an ordinary intentional source alongside the reconstruction and instruction
players: it implements the same protocol and is arbitrated by the same rules.
13 changes: 10 additions & 3 deletions docs/guide/interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ left, set up how the reconstruction is done in the centre, and click **Convert
sample** (or **Convert directory** for a folder). The
[instruction library](../concepts/instruction-library.md) for your settings is
built automatically the first time it is needed, so you can convert straight away.
When a single file finishes, **Load** opens the result on the **Reconstructions**
tab; **Cancel** stops a run, and only one runs at a time.
While it runs, the panel names the file going in and where the result is going, and
clicking either path shows it in your file manager. When a single file finishes,
**Load** opens the result on the **Reconstructions** tab; **Cancel** stops a run, and
only one runs at a time.

A few settings are worth knowing before you convert. Under **Reconstructor
settings**, the **Generators** toggles choose which channels take part — at least
Expand Down Expand Up @@ -78,7 +80,12 @@ redo, **Reconstruction** for the current reconstruction and its exports,
**Playback** for playing and for muting the sequencer's channels, **View** for
settings and the window, and **Help** for **About**.

Two of them are easy to miss. **View ▸ Show advanced settings** reveals the extra
Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...**
for the reconstruction on show, and **File ▸ Render song...** (`Ctrl+Shift+E`) for
the sequencer's whole song, as a WAV or an MP3 —
[rendering to audio](sequencer.md#rendering-to-audio) covers the options it offers.

Two other items are easy to miss. **View ▸ Show advanced settings** reveals the extra
options on the **Main** tab. **Playback ▸ Audio settings...** picks the playback
device, sample rate, and buffer size; these change what you hear, while the
**Sample rate** and **NES frequency** on the **Main** tab change how audio is
Expand Down
26 changes: 26 additions & 0 deletions docs/guide/sequencer.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,29 @@ When the song is ready, **Export as FamiTracker module** (or **File ▸ Export
FamiTracker module...**) writes the `.ftm`. See
[FamiTracker export](../formats/famitracker.md) for what the module contains and
the limits it respects.

## Rendering to audio

A module is for a tracker. To get a file anyone can play, use **File ▸ Render
song...** (`Ctrl+Shift+E`), which writes the whole song as audio.

The dialog holds the choices:

| Setting | What it does |
|---------|--------------|
| **Format** | **WAV** for the full-quality file, **MP3** for a smaller one |
| **Sample rate** | How many samples a second the file holds; 44100 Hz is the usual choice |
| **Bit depth** (WAV) | How finely each sample is stored. 16-bit PCM is the usual choice; 8-bit is there for the crunch the NES itself has |
| **Bitrate** (MP3) | How much the file spends per second — higher sounds better and takes more room. What is on offer depends on the sample rate, so the list follows when you change it |
| **Normalize peak** | Lifts the whole song so its loudest moment reaches full scale, keeping the balance between channels as it was |
| **File** | Where it is written. **Browse...** opens the save dialog, clicking the path shows where the file is going in your file manager, and the folder you pick is offered again next time |

**Length** tells you how long the file will be before you start. **Render** begins,
and a bar reports how far it has got; **Cancel** stops it and leaves the file
unwritten. When it finishes, _SampleToNES_ shows the file it wrote — click the path
to open its folder.

A render takes the song itself, once through, with every channel sounding: muting
and **Loop song** are for listening and stay out of the file. It is one of the long
jobs that run alone, so the item is unavailable while a conversion or a library
generation is going, and those wait for a render in the same way.
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The [**guide**](guide/) walks through the application from installation onward.
- [Installation](guide/installation.md) — the standalone build, running from source, and GPU acceleration.
- [Getting started](guide/getting-started.md) — your first reconstruction and your first song.
- [The interface](guide/interface.md) — the Main, Reconstructions, and Instructions tabs, and the menus.
- [The sequencer](guide/sequencer.md) — the tracker: arranging samples into a song and exporting a module.
- [The sequencer](guide/sequencer.md) — the tracker: arranging samples into a song, exporting a module, and rendering it to audio.
- [Command line](guide/command-line.md) — running without the graphical interface.
- [Where your files live](guide/files.md) — the folders and file types _SampleToNES_ uses.
- [Configuration](guide/configuration.md) — the settings you can change, and where.
Expand Down Expand Up @@ -56,7 +56,7 @@ The [**development**](development/) section is for contributors.

- [Architecture](development/architecture.md) — the application's layers and the contracts between them.
- [Undo engine](development/undo.md) — the design of the undo/redo subsystem.
- [Playback](development/playback.md) — the audio transport shared by every view.
- [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file.
- [Configuration](development/config-organization.md) — how the YAML configuration package is laid out.
- [Coding guidelines](development/guidelines.md) — conventions for the codebase.
- [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ dependencies = [
"rich>=13.0,<16",
"scipy>=1.13,<2",
"screeninfo>=0.8,<0.9",
"soundfile>=0.13,<0.14",
"tqdm>=4.66,<5",
"jeepney>=0.8,<1; sys_platform == 'linux'",
"pytaskbar>=0.1.1,<0.2; platform_system == 'Windows'",
Expand Down
1 change: 1 addition & 0 deletions scripts/checks/import_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

SERVICE_CONTRACTS = [
"sampletones_application.services.result",
"sampletones_application.services.render.result",
"sampletones_application.services.song_player.result",
]

Expand Down
Loading
Loading