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 @@ -2,6 +2,8 @@

## v0.3.2

* Added the tracker playback check: export your own project to [FamiTracker](http://famitracker.com) or [Bitphase](https://github.com/paator/bitphase), play it with the tracker's own playback code, and see every tick a channel sounds differently from _SampleToNES_. Run `sampletones tracker-playback`; the [guide](https://github.com/JakimPL/SampleToNES/blob/main/docs/tools/tracker-playback.md) explains what it needs and how to read its report.
* Changed how the noise channel plays a hand-written instrument, to match FamiTracker and Bitphase: an odd duty cycle value plays the short mode, and the pitch envelope moves the noise period.
* Added NSF player and export.
* Added stems conversion: to mix several recordings into one reconstruction.
* Changed drive to reach for louder instructions while a recording converts; reconvert anything converted at a drive other than `1.00`.
Expand Down
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Q := "
endif

GPU ?= auto
PROJECT_OPTIONS = $(foreach project,$(PROJECT),--project $(project))

help:
@echo $(Q)Available targets:$(Q)
Expand All @@ -25,7 +26,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 tracker-playback BITPHASE=folder FAMITRACKER=FamiTracker.exe [PROJECT=song.stp] - Play exported projects (the corpus by default) in Bitphase, FamiTracker or both; 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 @@ -54,7 +55,9 @@ calibration:
uv run sampletones calibration

tracker-playback:
uv run sampletones tracker-playback bitphase --checkout $(BITPHASE)
$(if $(BITPHASE)$(FAMITRACKER),,$(error Give BITPHASE=folder, FAMITRACKER=path/to/FamiTracker.exe, or both))
$(if $(BITPHASE),uv run sampletones tracker-playback bitphase --checkout $(BITPHASE) $(PROJECT_OPTIONS))
$(if $(FAMITRACKER),uv run sampletones tracker-playback famitracker --executable $(FAMITRACKER) $(PROJECT_OPTIONS))

clean:
$(PYTHON) scripts/clean.py
Expand Down
40 changes: 40 additions & 0 deletions THIRD-PARTY-LICENSES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4931,6 +4931,46 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


====================================================================================================
py65 1.2.0
License: BSD-3-Clause
Project: https://pypi.org/project/py65/
====================================================================================================

----------------------------------------------------------------------------------------------------
[py65-1.2.0.dist-info/LICENSE.txt]
----------------------------------------------------------------------------------------------------
BSD 3-Clause License

Copyright (c) 2008-2024, Mike Naberezny and contributors.
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


====================================================================================================
PyAudio 0.2.14
License: MIT License
Expand Down
133 changes: 133 additions & 0 deletions docs/concepts/timing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Song timing

This document explains how a song's tempo becomes the whole number of ticks each row lasts. Read it before
changing the groove, or when you need to know why the rows of a song last unequal ticks. You can read it
without the source code, which lives in `sampletones_core/timing/`.

## 1. The problem

A tracker plays a song row by row, and every row lasts a whole number of [ticks](../glossary.md#tick). The
project's settings ask for an exact row length:

```
ticks_per_row = speed × nes_frequency × 150 / (tempo × 60)
```

At tempo 150 and 60 Hz this is the speed itself. At most other tempi it falls between two whole numbers.
At 60 Hz, speed 6 and tempo 125, a row should last 7.2 ticks. A row can last 7 ticks or 8, so the song
mixes the two, and the mix decides how the rhythm feels. The ticks each row lasts are the song's
[groove](../glossary.md#groove).

The [meter](../glossary.md#metric-highlight) groups the rows. The first highlight is the beat and the
second is the bar. The defaults, 4 and 16, give bars of four beats of four rows. The tempo counts beats.

## 2. What a good groove does

Four properties describe a good groove.

1. **It keeps time.** Every bar starts on the tick nearest the moment the tempo puts it at. The rounding
never adds up, so the song holds its tempo however long it plays.
2. **It keeps proportion.** Every row lasts the whole number just below or just above its exact length:
7 or 8 ticks for 7.2.
3. **It keeps accents.** The longer rows go to the strongest positions. In a bar, the first half of the
beats comes first, then the first of each half, and so on down. In a beat, the first row comes first,
then the middle row, then the quarters. At 7.2 ticks a row, a bar of four beats plays:

```
8 7 7 7 | 8 7 7 7 | 8 7 7 7 | 7 7 7 7 115 ticks, against an exact 115.2
```

4. **It needs no look-ahead.** A player knows how long a row lasts when it reaches it, without reading the
rows after it.

**The four can conflict.** Suppose the speed could change in the middle of a beat. A player without
look-ahead would then have to decide the length of a beat's first row before knowing whether the beat
earns a surplus tick, which property 3 wants on that row. No procedure keeps all four in that case, while
any three of them can be kept together. A _SampleToNES_ song has one speed throughout, so its groove keeps
all four.

**Time is kept at the bar line.** Starting every beat on its nearest tick, too, would put the surplus
wherever the running count happens to land, which breaks property 3. So the bar lines stay exact, and a
beat inside a bar leans toward its strong positions. In common time a beat starts within about a tick of
its exact moment. A bar holding many beats is halved more times, so its beats can stray further: up to
about three ticks in a bar of 256 one-row beats, the deepest the settings allow.

## 3. How the ticks are placed

**The rate is held within what the engine plays.** A row lasts at least one tick. A tempo above
`2.5 × speed × nes_frequency` asks for shorter rows, so every row lasts one tick and the song plays slower
than its tempo states. FamiTracker and Bitphase play such a tempo the same way.

**Each bar takes the ticks between its own start and the next bar's start.** Each start is rounded to its
nearest tick, counted from the song's first row. This is what keeps time: no bar line is more than half
a tick away from its exact moment, however far into the song it falls.

**Two frames of one pattern can differ by a tick.** At 60 Hz, speed 6 and tempo 210, a 16-row pattern
lasts 68 4/7 ticks. The first frame plays 69 and the second 68, as the bar lines fall:

```
5 4 5 4 5 4 4 4 5 4 4 4 5 4 4 4 69 ticks
5 4 4 4 5 4 4 4 5 4 4 4 5 4 4 4 68 ticks
```

Once the exact lengths of a run of patterns add up to whole ticks, the grooves start over: here every
seven patterns, which last 480 ticks. A pass through the whole song lasts the whole number of ticks
nearest its exact length, and a song that repeats replays that pass.

**A bar's ticks are halved down to its rows.** The bar's beats are cut in two, the larger part first, so
three beats part as two and one. Each part takes the share of the ticks nearest its exact length, held to
what its rows can carry, so every row keeps proportion. Each part is halved again, and a single beat is
cut between its rows the same way. The surplus therefore settles on the strongest positions. Three rows
sharing 23 ticks, for example, play `8 7 8`.

**The meter restarts at every pattern,** as a tracker's highlights do. A pattern that isn't a whole number
of bars ends on a shorter bar. A beat of 3 rows in a bar of 7, over a 16-row pattern, gives bars of 7, 7
and 2 rows, with beats of 3, 3 and 1 in the first two.

**Every player reads the same timing.** In-app playback, rendering, the NSF export, the Bitphase export
and the tracker playback check all ask how long a row lasts by the row's place in the song. Each answer
follows from that place alone, so a player starting from any row reads its length directly.

## 4. Ticks and the clocks they play on

Rows are planned in engine ticks, at the song's NES frequency. An instrument's envelope moves once a tick,
and a note starts on a tick, so the tick is the unit a row's length can be counted in. Each way of
playing the song then turns ticks into its own time once:

- **In-app playback and rendering** turn each tick into audio samples. A tick rarely lasts a whole number
of samples, so the samples are spread the way the ticks are spread over rows: the fraction carries from
tick to tick.
- **The NSF** runs on the console's own call, about 60.1 times a second on NTSC. Its driver adds the song's
rate, measured in calls, to a counter on every call and plays the ticks that fall out. A 30 Hz song
moves on every other call, a 41 Hz song holds about one call in three, and a 300 Hz song moves up to
five ticks a call. Any rate the settings allow therefore plays at its own speed.

Each clock carries its own remainder, so neither adds up over a song. A bar line the console plays lands
within half a tick of its exact moment plus one call. Planning the rows in ticks also keeps the NSF on the
rhythm the app plays, and its stream holds only engine ticks, so a phrase repeats wherever it is played.

## 5. The exports

**The NSF plays the app's ticks.** Its stream holds the ticks the song walk plays, and its driver re-clocks
them onto the console's call (section 4). The stream stores how long each row's phrase lasts beside the
phrase, so a frame whose groove differs from the one before costs a few more bytes than a frame that
repeats it.

**Bitphase plays the app's rows.** Its song has a speed and no tempo, so the export writes a speed effect
on every row that lasts differently from the row before it. [Tempo as a
groove](../formats/bitphase.md#d-tempo-as-a-groove) has the details.

**FamiTracker places the longer rows by its own count.** The export writes the project's speed and tempo
as they stand, and FamiTracker carries the rounding from row to row as it plays. Its song keeps the tempo
over time, and the longer rows fall wherever its running count lands. A module's rows can therefore last
a tick more or less than the app's, while every bar stays close to where the tempo puts it. A NES
frequency other than 50 or 60 Hz reaches FamiTracker as its engine speed.

## 6. How it is checked

The test suite holds the groove of every combination of tempo, speed, NES frequency and meter it sweeps
to the properties above, across many frames of a song. It measures how far each bar line lands from its
exact moment, checks that every row lasts the floor or the ceiling of its exact length, and checks which
rows of a beat and which beats of a bar carry the surplus. It also follows the bar lines through the
console's call at several rates, and holds what a player spends asking for a row's length to a small
share of what walking the song costs.
4 changes: 2 additions & 2 deletions docs/development/application/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Two terms recur. A **source** is what plays one kind of audio: a reconstruction,
4. **Surfaces describe the target, and the transport decides.** The menu, the toolbar and the keyboard reach identical verbs and report identical state, so a new surface adds another way in to the same behavior.
5. **Listening choices stay out of the document.** What the user chooses to hear is session state, and 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. 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. A listening control therefore takes 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 meter together with the row's place in the pattern. 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. The integer tick counts the [groove](../../glossary.md#groove) places *are* the tempo, so a render realizes them exactly at every rate it offers.
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 meter together with the row's place in the song. 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. The integer tick counts the [groove](../../glossary.md#groove) places *are* the tempo, so a render realizes them exactly at every rate it offers.

## Two kinds of sound

Expand Down Expand Up @@ -137,7 +137,7 @@ The device holds a release per stream it handed out and invokes it whenever it n
| Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) |
| 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/`) |
| How long a row lasts, and how many samples its ticks span | `SongTiming` and `TickClock` (`sampletones_core/timing/`) |
| Rendering the song to a file, its passes and its progress | `SongRenderService` (`services/render/`) |

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.
5 changes: 2 additions & 3 deletions docs/development/bugs-and-todos.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,11 @@ dimension the import starts carrying.
* A FamiTracker transpose row moving a note more than fifteen semitones, or a shared pattern's cell that
frames reach needing different slides, is written without its slide and reported. A second effect
column, or a pattern cloned per frame, would carry it.
* A FamiTracker bend ending before its arpeggio loses the offset it ends on, since the running arpeggio
reloads the period from the note and a halted bend adds nothing. Only instruments a note slide reaches
circle the bend on its last item today.
* A FamiTracker module's pulse level can sound a step away from in-app playback. FamiTracker rounds the
product of the two levels down and keeps the quietest level where that comes out silent, while the app
and Bitphase round it to the nearest step.
* A FamiTracker module places the longer rows of an uneven tempo by FamiTracker's own running count.
A compatibility setting writing a speed effect per row at tempo 150 would make it play the app's groove.

### Workflow

Expand Down
7 changes: 4 additions & 3 deletions docs/development/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,10 @@ The driver's assembler and the register trace live in `sampletones_tools/player/
neither. The assembler builds the committed `driver/binary/driver.bin` from the assembly sources beside it.
The tests rebuild the sources wherever cc65 is installed and hold the committed image to them.
`RegisterTrace` says what the driver is expected to write, call by call, and the emulator tests hold the
assembled driver to it. Exporting reads the assembled binary. The wheel carries the assembly sources
beside it, inside the tools package. [`dependencies.md`](release/dependencies.md) describes the toolchain
the build needs.
assembled driver to it. They run it on the console in `sampletones_tools/console/`, which plays any `.nsf`
the way an NSF player does, loaded whole or in switched banks. Exporting reads the assembled binary. The
wheel carries the assembly sources beside it, inside the tools package.
[`dependencies.md`](release/dependencies.md) describes the toolchain the build needs.

---

Expand Down
4 changes: 2 additions & 2 deletions docs/development/release/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ cc65 is distributed under the zlib license. The link line names our own object f

### Verifying the driver

[py65](https://github.com/mnaberez/py65), a 6502 emulator in the `dev` dependency group, executes the assembled driver against memory that watches the APU's address range. That lets the suite hold the image to what a correct driver writes ([the console player](../player.md)). py65 is a developer dependency, outside both the wheel and the bundles, and its BSD license leaves the project's own terms untouched.
[py65](https://github.com/mnaberez/py65) is a 6502 emulator. The console in `sampletones_tools/console/` runs an `.nsf` on it the way an NSF player does, against memory that watches the APU's address range. That lets the suite hold the image to what a correct driver writes ([the console player](../player.md)). The tools package ships with every install, so py65 is a runtime dependency: the wheel names it and the bundles carry it. Its BSD license leaves the project's own terms untouched, and `THIRD-PARTY-LICENSES.txt` has its text.

Listening to a real APU needs [ffmpeg](https://ffmpeg.org/) with the `libgme` demuxer, which is a build option and not a given. `uv run sampletones nsf render` asks the installed ffmpeg which demuxers it has, and names this system's install command before it decodes anything.

CI reaches only py65, because the workflows install the `dev` group and `scripts/system_dependencies.py` has what building and running the application needs. cc65 and ffmpeg stay on the machine of whoever assembles the driver or renders a wave. A workflow that did either would put them in those scripts. The application itself needs neither: an export is written by the package's own code, from the committed `driver.bin`.
CI runs the driver on py65, which arrives with the package's own dependencies, and `scripts/system_dependencies.py` has what building and running the application needs. cc65 and ffmpeg stay on the machine of whoever assembles the driver or renders a wave. A workflow that did either would put them in those scripts. The application itself needs neither: an export is written by the package's own code, from the committed `driver.bin`.

## Linux (standalone executable)

Expand Down
Loading
Loading