Why rebuildap exports labels the way it does, and what it costs.
Back to the README.
A label file is named <track>_<stem>.txt, and <stem> is the open project's
.aup3 filename stem - not the name of its audio track.
The two are usually the same, because a project rebuilt from song.opus is
saved as song.aup3 and its audio track is called song. They come apart as
soon as a project is copied: Save-As on song.aup3 produces song_G.aup3 whose
audio track is still called song. This is the normal way to keep a transposed
variant beside its original, and naming by the audio track made the variant write
its labels straight over the original's files.
Audacity has no query for "which file is this project?" - AXDocument is
missing value and there is no GetInfo: Type=Project (see
Audacity quirks). So open_project_stem intersects the
two partial routes:
- window titles say which projects are open, but carry no path, and an unsaved project is titled with the stem of the audio it was built from;
- Open Recent gives real, full paths, but is a recency list - it holds closed projects and can evict open ones.
A name that is both an open window and a file on disk is the project. Two same-stem projects in different directories collapse to one answer, which is correct here: the stem is the same either way.
When that yields several differently-named projects, the frontmost one
wins. That is not a guess: mod-script-pipe acts on the frontmost project window,
measured on 3.7.8 (2026-07-28) with two projects open and a uniquely named label
track in each - the tracks reported by GetInfo followed the window focus. So
the project rebuildap names its files after is the same project the commands
themselves touch.
It refuses only when that tiebreaker cannot be applied: something other than a
project is frontmost (a modal dialog, the About box), or the front window cannot
be read at all - frontmost_audacity_window_name() returns None for an
Accessibility refusal as well as for "no windows", so None is never taken as an
answer. Bring the project you mean to the front and run again.
A single candidate is returned without consulting the frontmost window at all, so a dialog sitting in front of the only open project cannot turn a working export into a refusal.
One residual: the tie is broken at the moment identity is resolved, and nothing stops you switching windows mid-run. It is a seconds-wide gap, and every mode prints the full path it wrote, so a mis-targeted run is visible in its own output rather than silent.
When it yields nothing - a never-saved project, or one evicted from Open
Recent - it falls back to the audio track's name and says so on stderr. A
never-saved project has no .aup3 stem to find, and refusing to export it would
be worse than naming it after its audio; announcing it is what keeps the
fallback from going unnoticed, as the old unconditional behaviour did.
This applies to every command that acts on whatever project is open: a bare
export, quantize and transpose. Passing an .aup3 explicitly (check song_G.aup3) has never had the problem - the stem comes from the path you gave.
After a rebuild, rebuildap moves the label files' mtimes up to match the newly
saved .aup3 (forward only, never backwards, and their content is untouched).
Without that, check would treat every label file as older than the project and
so re-export and diff it on every run, forever. It only rewrites a label
file when the content actually diverges, so the condition would never clear on
its own. The rebuild is proof that the project and the label files agree — it was
built from those very files — so recording that agreement in the mtimes is
accurate rather than a fudge.
The same mtimes drive check, in the other direction. An Audacity edit always bumps
the .aup3 to newest (see
When an .aup3 changes on disk),
so if every label file is newer than the project, no edit can have outrun them
and there is nothing an open could reveal:
All label files are newer than song.aup3. Nothing to do.That is the gate's whole job, and it is worth having: it skips a 2-3 second project open, a GUI window, and one more interaction with a crash-prone dependency — per project, across a sweep of many.
The gate is a whole-project decision, not a per-file filter. Once the project is open, every label file is compared, newer ones included. It used to compare only the older ones, which was close to free to change and quietly wrong:
GetInfofetches every label track in a single call, so a track is already in memory whether or not its file passes any filter.- Comparing one more file costs about 0.2 ms (measured over 654 real label files), against 2-3 s for the open it cannot avoid. The per-file filter saved nothing measurable.
- It hid tracks.
quantizeandtransposerewrite a label file after reading the project, so the file they just wrote is newer than the.aup3— and acheckrun would list three tracks while silently omitting the fourth, indistinguishable from a project that only has three.
check -f skips the gate: open and compare even when every label file is newer.
That is for when mtimes lie across the board — a git checkout, a touch, a
restore — leaving files newer than the project while their content has diverged.
Comparing a newer file cannot damage it. A divergence writes the export
artifact <track>.txt, never the versioned <track>_<stem>.txt, so a .txt
that quantize or transpose just wrote is never overwritten by a check.
(check -f was spelled -c -d until 2026-07-25, and -c -f until the commands
replaced the mode flags. It became -f to join the other commands' "ignore the
narrowing rule, do the whole thing" flag — the same -f that exports into the
current directory anyway, or acts on the whole track rather than the time
selection.)
Audacity doesn't support exporting label tracks selectively: when exporting via File > Export Other > Export Labels…, all label tracks get concatenated into a single file. There's a workaround — temporarily remove all but one label track, export, then undo.
The downside of the workaround is that it requires the user to click through
the save dialog once per label track. Audacity's scripting pipe (GetInfo: Type=Labels) offers a non-interactive alternative, at the cost of some
precision.
Empirical comparison (on a project with beat-quantized labels plus one
manually-placed sub-beat label C7#9):
| source | start | end |
|---|---|---|
interactive ExportLabels: (dialog) |
125.437458 |
126.671804 |
GetInfo: Type=Labels (non-interactive) |
125.437 |
126.672 |
GetInfo truncates to 3 decimals while the interactive export preserves
6 decimals. Maximum rounding error: ~0.5 ms, i.e. ~22 samples @ 44.1 kHz —
inaudible but not sample-accurate.
For beat-quantized labels (the common case, e.g. output of
DBNDownBeatTracker
rounded to 2 decimals) both paths yield identical files.
rebuildap uses the non-interactive GetInfo path exclusively. The interactive
dialog path was offered behind -p / --precise until 2026-07-18 and has been
retired: ExportLabels: takes no parameters (see the
scripting reference),
so the save dialog alone decided where each artifact landed and the caller had
to guess that location afterwards — it read them back from the current working
directory, which was wrong whenever rebuildap was invoked from elsewhere.
Combined with one dialog click per label track, the ~0.5 ms precision edge did
not justify keeping a second export path alive.
A Nyquist plug-in route
was investigated and rejected: Nyquist-side label access also goes through
aud-get-info (see Steve Daulton's ExportAllLabelTracks1.ny for reference),
so a custom Nyquist plug-in would have the same 3-decimal precision floor
as our direct GetInfo path — no advantage.
Import runs through the Nyquist plug-in
ImportLabels.ny,
which requires each line to have exactly three tab-separated fields (two
tabs), start<TAB>end<TAB>text, with the text possibly empty. It builds a Lisp
expression per line and only opens the string quote after the second tab, so a
line with fewer tabs produces malformed Lisp and the whole import fails with a
bare BatchCommand finished: Failed! — no indication of which file or line.
Real label files in the corpus come in three shapes, and two of them are not directly importable:
| source | shape | example |
|---|---|---|
| raw beat times | 1 column | 0.470 |
DBNDownBeatTracker single |
2 columns, time<TAB>num |
0.470 4 |
| Audacity's own export | 3 columns | 0.47 0.47 |
rebuildap normalizes every line to the three-field canonical form in a
throwaway temp file and imports that; the versioned source is never touched.
The same normalizer (rebuildap.utils.normalize_label_line) is what check mode
compares both sides through, so a 1-column source and its 3-column re-export
compare equal — the round trip is a fixed point, not a permanent divergence.
A 2-column line is always a point label whose text is field 2 — rule A —
even when field 2 is numeric (0.470 4 -> 0.47 0.47 4). The only 2-column
source here is DBNDownBeatTracker, whose second column is a downbeat number
(1..4), i.e. label text, not an end time. Reading it as start<TAB>end yields
backwards regions (1.12 1 -> a region ending before it starts). No genuine
start<TAB>end region source exists — the exporter always writes three fields —
so nothing is lost. See decisions.md (2026-07-21).
Two subtleties that the normalizer must respect:
- Only the line terminator is stripped, never the field tabs. Audacity
writes an empty-text label as
start<TAB>end<TAB>(trailing tab); collapsing that to two fields would make rule A misread the end time as text and break the check-mode round trip for every 1-column project. - Only times are trailing-zero-trimmed (
0.470->0.47); the text field is taken verbatim (3.500stays3.500).
A line that matches none of the three shapes (a stray header, a blank-but-junk
line) is rejected before Audacity is started, with a LabelFormatError
naming the file and line number — the same fail-before-launch stance as the
already-open and aup3-exists guards, so a bad file costs ~0.07 s and leaks no
window.