Skip to content

feat(core): build a script's document: fields, media, its length and timelines - #748

Open
azfoo wants to merge 3 commits into
v1-prereleasefrom
v1/275-build-document
Open

azfoo wants to merge 3 commits into
v1-prereleasefrom
v1/275-build-document

Conversation

@azfoo

@azfoo azfoo commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Claude

Adds tilia_core.build. It holds what a script needs besides importing: a new document, its fields, its media and media length, and empty timelines. Everything happens in memory, and only tla.write writes a file.

  • new_script_document() gives tla.new_document() with the fields title "Untitled" and notes "", in that order, and no others.

  • set_field(document, name, value) sets a field, or adds it after the others. The value is kept as NFC text, or a list of NFC texts; anything else raises TypeError. media length, in any letter case, raises TiLiA's CLI_METADATA_CANT_SET_MEDIA_LENGTH text.

  • set_media_length(document, seconds) refuses:

    • a value that isn't a positive, finite number, with TiLiA's MEDIA_METADATA_SET_DATA_FAILED text. This includes text, bool, NaN and infinity, which a file can't hold.
    • any length while media is attached, with CHANGE_MEDIA_LENGTH_WITH_MEDIA_LOADED.
    • a change to a known length once any timeline has components, with "The media length can't change once timelines have components: rescale the file in TiLiA.". Components that a timeline of an unknown kind keeps in raw count too. Setting the same length again is allowed, and so is a first length. "The same" means equal, as the spec says; whether a length within a millisecond counts as the same waits for TimeLineAnnotator/project-management#327 (a media length within a millisecond).
  • load_media(document, path, *, duration_if_error=None) records a YouTube link as given. A local path must be a file (else MEDIA_NOT_FOUND, also for a folder or a path the system can't look at). It is stored as TiLiA's command line stores it: resolved, absolute, with /. The writer makes it relative to the saved file. The length comes from:

    • for a .wav (in any case), its header;
    • else duration_if_error, checked as set_media_length checks a length;
    • else the length already set, but only while no media is attached;
    • else nothing: ValueError with <name>: give its length with --duration-if-error, or set it first with metadata set-media-length.

    The change rule of set_media_length applies. It returns the length set.

  • wav_length(path) reads the RIFF header with struct: the data chunk's size over the fmt chunk's byte rate, or block align × sample rate when the byte rate is 0.

    • It reads the oldest, 14-byte fmt chunk too, and skips other chunks and their pad bytes.
    • A data size past the end of the file (a WAV written as a stream gives 0xFFFFFFFF) counts the bytes that are there.
    • It gives None for a file that isn't a RIFF WAVE, a header it can't read, a length of 0, or an OSError.
    • The wave module refuses 32-bit float files on every version, and WAVE_FORMAT_EXTENSIBLE before 3.12. This reader reads both.
  • add_timeline(document, kind, name="", *, height=None, beat_pattern=None) adds beat, hierarchy, marker or harmony after the others (ordinal = the largest + 1), with a new id. Its attrs are the format's defaults, as the reader fills them in, so a document built here reads back from its bytes the same.

    • Without a height, a timeline gets TiLiA's out-of-the-box default: marker 30, hierarchy 120, beat 69 (35 plus the 34 of the time-signature band). A harmony timeline has none.
    • A beat timeline shows its time signatures (the format's default) and keeps beat_pattern as given, "4" by default.
    • Refusals, in TiLiA's order and with its texts:
      • while the media length is unknown (CLI_CREATE_TIMELINE_WITHOUT_DURATION);
      • --beat-pattern for any kind but beat, and --height for harmony (CLI_ADD_TIMELINE_ARG_NOT_APPLICABLE);
      • a pattern that tilia_core.timemap.pattern.parse doesn't complete (Invalid beat pattern '10[': Unclosed '['.).
    • A height that isn't an integer of at least 10 is refused with 5 is not a valid height., as TiLiA's set_data refuses one (TimeLineAnnotator/project-management#316 (which heights timelines add accepts)).
    • A beat pattern that isn't text raises TypeError.
  • find_timeline(document, *, name=None, ordinal=None) finds by ordinal first, as TiLiA's command line finds an import's target, or else the first timeline in ordinal order with that name (compared in NFC). If none matches, it raises No timeline found with name='…' or No timeline found with ordinal=….

  • Two stand-ins, which TimeLineAnnotator/project-management#317 (replace build.py's stand-ins) replaces:

  • tests/importers/import_docs.py uses tla.FORMAT_VERSION and drops its own copy of the version.

Tests

  • tests/importers/test_build.py, written first:
    • every refusal, each checked word for word and leaving the document unchanged;
    • each default height and a height given;
    • WAVs made in the test, each with its exact length: 16-bit PCM from wave, and by hand 32-bit float with a fact chunk, 24-bit WAVE_FORMAT_EXTENSIBLE with an odd-sized chunk before the data, a 14-byte fmt chunk, a zero byte rate, and a streamed data size;
    • headers it can't read, including another RIFF form and big-endian RIFX;
    • a folder and a name too long for any file system as media;
    • YouTube links;
    • each case of the media-length change rule;
    • a built document written with tla.canonical_bytes and read back with tla.loads, the same fields and timelines, with each height in the file.
  • uv run --package tilia-core pytest packages/tilia-core -q: 912 passed, 21 skipped, 8 xfailed. The suite passes on Python 3.10 too. python scripts/check_packages.py passes. black (22.12.0) and ruff are clean.

Guarantees. I neutralised the code behind each one, ran test_build.py (118 tests), then restored it:

Guarantee Code neutralised Tests failing
media length isn't a field the check 3
… in any letter case lower() 2
a field is text or a list of texts the TypeError 4
a media length is above 0 length > 0 6
… finite isfinite, keeping > 0 4
… a number, not text or bool the type check 4
no length while media is attached the check 3
a known length doesn't change with components the check 3
… the same length is no change length != known 2
… a first length is no change known is not None 2
… raw components count the raw check 1
load_media checks the change too its call 1
a failed load_media changes nothing media_path set before the length is found 10
a local path must be a file the check 3
… not a folder isfile back to Path.exists 1
an earlier length only while no media is attached not media_path 1
duration_if_error is checked the check 4
a WAV's length is read the call 9
.WAV counts lower() 1
an unreadable file gives None except OSError 1
only RIFF WAVE files the magic check 2
a short chunk header ends the search the check 3
a short fmt chunk gives None the check 1
a 14-byte fmt chunk is enough requiring 16 bytes 1
block align × rate without a byte rate the fallback 1
a data size past the end counts the bytes there min 1
pad bytes after odd chunks % 2 1
a zero rate or empty data gives None the check 2
no timeline without a media length the check 1
only the four kinds the check 5
a beat pattern only for beat the check 3
… and only text the type check 2
no height for harmony the check 1
a height is checked the check 6
… at least 10 >= 0 3
a pattern must parse the check 4
the format's defaults are filled in attrs = {} 2
after the others len(timelines) + 1 1
names, fields and list items in NFC each nfc() 1 each
the first by ordinal the sort 1
an ordinal before a name the precedence 1

A path the system can't look at raises OSError from Path.exists only on Linux and macOS before Python 3.12. On Windows, where I ran these, putting Path.exists back fails only the folder's test. The long name's test is there for CI's Linux and macOS jobs on 3.10 and 3.11, where it should fail without the fix; I couldn't check that on Windows.

Part of TimeLineAnnotator/project-management#275

🤖 Generated with Claude Code

azfoo and others added 3 commits October 15, 2026 12:26
The helper kept its own copy of the format version until the writer
brought tla.FORMAT_VERSION.

Part of TimeLineAnnotator/project-management#275

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…timelines

tilia_core.build gives what a script needs besides importing:

- new_script_document: a new document with title "Untitled" and empty
  notes, and no other field.
- set_field: sets a field, or adds it after the others; "media length"
  is refused, as TiLiA's command line refuses it.
- set_media_length: a positive, finite number of seconds; refused while
  media is attached, and for a change to a known length once timelines
  have components.
- load_media: records a local file (absolute, as the writer makes it
  relative) or a YouTube link. A WAV's length is read from its header;
  other media take duration_if_error, or the length set while no media
  was attached.
- wav_length: reads the RIFF header with struct, so 32-bit float and
  WAVE_FORMAT_EXTENSIBLE files, which the wave module refuses, have a
  length too.
- add_timeline: beat, hierarchy, marker or harmony, after the others,
  with TiLiA's out-of-the-box heights (marker 30, hierarchy 120, beat 69)
  when none is given. It refuses while the media length is unknown, a
  height for harmony, a beat pattern for anything but beat, a pattern
  that doesn't parse, and a height below 10.
- find_timeline: by ordinal, or the first by ordinal with a name.

The messages are TiLiA's where TiLiA has one. The YouTube pattern is a
copy of TiLiA's until the core's media module has one.

Part of TimeLineAnnotator/project-management#275

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- load_media takes a file only: a folder is refused as missing media, and
  a path the system can't look at (a name too long, no permission) is
  refused the same way instead of raising OSError. os.path.isfile rather
  than Path.exists, which raises for those before Python 3.12.
- wav_length reads the oldest, 14-byte fmt chunk, which holds the rate,
  the byte rate and the block align.
- add_timeline raises TypeError for a beat pattern that isn't text,
  rather than failing inside the parser.
- A beat timeline's show_time_signatures comes from the format's
  default, which is already true; the line that set it again is gone.
- Tests for set_field's lists of texts and its TypeError, and issue
  references with their names.

Part of TimeLineAnnotator/project-management#275

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant