Schema and test suite
Three artifacts ship alongside this specification: a schema a validator can read, the same schema in a second format, and a corpus of files to check an implementation against.
/schema/thaimusicxml-1.0.rng is the schema. It is RELAX NG in XML syntax, so xmllint and anything else built on libxml2 reads it without conversion:
xmllint --noout --relaxng thaimusicxml-1.0.rng score.txml/schema/thaimusicxml-1.0.xsd is the same schema translated to W3C XML Schema, for tools that read XSD and not RELAX NG. It’s generated from the .rng file by Trang on every push; the RELAX NG file is the one actually hand-edited, so treat the XSD as downstream of it.
The corpus lives under /corpus/, split into files that must be accepted and files that must be rejected. Both are plain static files. Download them, point your own validator at them, and see whether it agrees.
What the schema covers, and what it cannot
Section titled “What the schema covers, and what it cannot”A grammar describes one document at a time, element by element. That reaches further than it might sound: the schema catches an unknown pitch spelling, a chan value of 0.50 where only 0.5 is exact, a <bow type="stop"> carrying a direction it has no business carrying, a ninth <measure> in a <line>, a <group> with one child, and a <part> with stack but no row.
What it cannot reach is anything that depends on the rest of the document. Whether a <measure> may hold <syllable> children turns on the type of a <part> declared elsewhere in the file. Whether two parts agree on beat count is a comparison between two branches. Whether an <ending> names a pass that exists depends on how many <repeat> elements enclose the section, several levels up.
So conformance has two layers, and passing the schema is the first of them, not the whole of it. The Conformance reference states every rule; scripts/check-corpus.mjs in the repository implements the ones the grammar cannot express, and runs both layers over the corpus.
One deliberate omission is worth naming. The schema does not declare id attributes as xsd:ID, even though that would buy uniqueness checking for free, because xsd:ID is unique across the whole document while ThaiMusicXML scopes ids per kind: a <part id="1"> and a <section id="1"> are different names that do not collide. Uniqueness is checked per kind outside the grammar instead.
Valid files
Section titled “Valid files”Every file here must be accepted. Between them they exercise right-anchored groups, stacked instruments, lyric parts, spans crossing measure and line boundaries, nested repeats, and variant endings.
| File | What it exercises |
|---|---|
aligned-text.txml | Aligned text runs. An annotation carries all three positions on one line; the credits each carry one. The text prints exactly as written, which is why the label is part of the string rather than generated. Note that the three positions are placed independently, so document order does not decide where they land. |
ending-inherits-empty-measure.txml | A completely empty <measure> inside an <ending> line means "unchanged from the line being replaced" - the arranger only has to write out the measures that actually differ. Measure 1 here is left empty on pass 2, so it plays measure 1 of the base line 2 unchanged; only measure 2 actually varies. |
ending-with-span.txml | A bow span reaching into a line an ending overrides. The stop appears in both versions of line 2: once in the regular line for the pass that plays it, once in the ending's line for the pass that plays that instead. Neither pass sees two stops, because neither pass plays both versions. |
extensions-and-ids.txml | Optional ids on line, measure, note, rest, and group, plus a foreign-namespace extension element after the last <part-data>. |
line-annotation.txml | Annotations at both scopes in one part: one on the <section-ref>, which heads the section, and one on a <line>, which heads that line wherever it falls. A line annotation takes <text> children like any other. |
line-repeats.txml | Line repeats. One range nests inside another and a third is disjoint from both, which is the only arrangement allowed: ranges must be properly nested or wholly separate. A line repeat is independent of any <repeat> around the section and does not affect how spans are matched. |
link-across-beats.txml | A link span reaching past one beat. The markers sit either side of two groups, so the run they mark is four notes long - the case a boolean on <group> could not express, since a group is only ever one beat. The second span opens inside a group and closes inside the next one. |
link-single-row.txml | A link span in a part with no stack. There is no other row to reach, so the curve marks the span's own notes. |
lyric-ending.txml | A lyric part varying its words on the second pass. The ending's measure holds two syllables where the line it replaces holds one syllable and a rest: a lyric part's item count is its own business in an ending as anywhere else, and only the measure count has to match. |
lyric-part.txml | A lyric part beside an instrumental one. Measure 1 has four items and so aligns to the beats; measure 2 has three and is centered in the cell instead. The rests are เอื้อน: no new syllable begins, the vowel already being sung carries on. A lyric part shares the line and measure grid but is exempt from agreeing on beat count. |
minimal.txml | The smallest legal ThaiMusicXML document: one part, one section, one line, one four-beat measure. |
pitch-spellings.txml | The three interchangeable spellings of a base note, the two Thai octave modifiers, and the octave attribute. Measure 1 is numeric, measure 2 romanized (mixed case, which carries no meaning), measure 3 Thai script, measure 4 the modifiers and octave. A real file picks one spelling and keeps to it; this one exercises all of them on purpose. |
repeats-and-endings.txml | Nested repeats and a variant ending. The section sits inside a times="2" within another times="2", so its total pass count is 4 and pass numbers run 1 to 4 absolutely, regardless of which layer produced each play. The ending replaces the section's last line on passes 2 and 4. |
right-anchored-groups.txml | Right-anchored subdivision. Both parts agree on four beats per measure while filling them differently: P1 plays four plain notes, P2 splits beats 1 and 3 into groups. A group's final child lands on the beat and the earlier ones space backwards from it. |
section-omitted-by-part.txml | A part that sits out a section. P2 is silent for the whole of ท่อน 2, so its part-data simply has no section-ref for it. The cross-part agreement rule then applies only to the parts that do reference the section. |
section-replayed.txml | ABA form through <play>: ท่อน 1 is declared once and played twice, the second time after ท่อน 2, without a second copy of its music. That gives it two passes, so it can carry an <ending> for the return. |
spans.txml | Bow and parenthesis spans. The bow in line 1 crosses a measure boundary; the bow opened at the end of line 1 closes in line 2, which renders as two arcs but is one span. The parenthesis span sits inside a group, showing that markers carry no duration and do not count toward the group's division. |
stacked-instrument.txml | A two-row instrument. Both parts share a stack value and number their rows from 1, and they sit adjacent in <ensemble> in row order. A link span in row 1 draws a connector to what row 2 plays over the same beats. Each row also carries an instrument-short-name, since the ensemble label column has only the page margin to work with. |
unpitched-part.txml | An unpitched part. Notes carry sound rather than pitch, and the codes are instrument-specific, so an annotation inside the section reference says what they mean. The rests are beats with no attack. |
version-mismatch.txml | A conforming document whose version attribute disagrees with its namespace. The namespace is the compatibility boundary, so this is read as 1.0 and draws a warning rather than a rejection. |
warnings.txml | A valid document that a conforming processor should nevertheless warn about, four times over. Nothing here makes the file invalid: the rule in each case is warn, never reject. - nathap and tuning carry values outside the recommended lists, which is legal because neither set is closed, but catches a typo in a known name - the composer has <text> children, so the text beside them is discarded rather than printed - octave sits alongside a Thai octave modifier, where the modifier wins - octave sits on a note that uses sound, where it has no meaning |
warnings.txml is the odd one. It is a conforming document that a processor should nonetheless warn about five times over, because every rule it brushes against is one where the specification says warn rather than reject.
Invalid files
Section titled “Invalid files”Every file here must be rejected, and each one breaks exactly one rule so that a failure points somewhere specific. The comment at the top of each file names the rule it violates.
These are counterexamples, not models. Nothing in this directory is a file to copy from.
| File | What it violates |
|---|---|
beat-count-mismatch.txml | Corresponding measures must agree on beat count across parts. P1 has four beats where P2 has three. |
bow-span-unclosed.txml | A bow start must be closed by a stop within the same section reference. This span is left open at the end of the pass. |
bow-spans-overlap.txml | A bow start must be closed before another start appears. Bow spans cannot nest or overlap. |
bow-start-without-direction.txml | direction is required on a bow start. |
bow-stop-with-direction.txml | direction must not appear on a bow stop. The span already carries its direction from the start. |
bow-stop-without-start.txml | A bow stop with no open span before it. |
bpm-zero.txml | bpm content must be a positive integer. |
chan-value-not-exact.txml | chan is a closed enumeration matched exactly. "0.50" is not "0.5". |
duplicate-note-id.txml | Two notes share an id. Ids are unique within their own kind, the same rule as part and section ids, extended to note. |
duplicate-part-id.txml | Two parts share an id. Ids must be unique within their kind. |
duplicate-section-id.txml | Two sections share an id. |
ending-beat-count-differs.txml | Corresponding measures in an ending line must keep the beat count of the line they replace. Only the notes inside may vary. |
ending-duplicate-line-for-pass.txml | Two endings must not cover the same line number for the same pass. |
ending-line-not-trailing.txml | An ending's lines must form a consecutive run through to the section's last line. This one covers line 2 of a three-line section and then stops. |
ending-line-number-missing.txml | Each line in an ending must match the number of a line already present in the section-ref. There is no line 4. |
ending-measure-count-differs.txml | An ending line must have the same number of measures as the line it replaces. |
ending-on-unrepeated-section.txml | An ending is only valid in a section whose total pass count is greater than 1. This section plays once. |
ending-pass-descending.txml | pass values must be listed in ascending order with no repeats. |
ending-pass-exceeds-total.txml | pass values must fall within the section's total pass count, which is 2 here. |
ending-pass-zero.txml | pass numbers are 1-based. Zero is not a pass. |
ending-without-annotation.txml | An ending must carry at least one annotation. It prints away from the line it replaces and needs a caption saying which part it belongs to and when it applies. |
extension-without-namespace.txml | An extension element after <part-data> with no namespace of its own, indistinguishable from native markup. |
group-nested.txml | Groups do not nest. Subdivisions in v1.0 go one level deep. |
group-one-child.txml | A group must contain at least two note or rest children. There is nothing to subdivide a beat between here. |
header-out-of-order.txml | header children are ordered. title comes first. |
id-with-whitespace.txml | an id containing a space. Ids are matched character for character, so they carry no whitespace: an id that had to be trimmed or collapsed before matching would resolve differently in two processors. |
line-annotation-after-measure.txml | a <line>'s annotations come before its measures, the same order <section-ref> uses. One sitting between two measures is rejected. |
line-count-mismatch.txml | All section-refs for one section must agree on line count. |
line-nine-measures.txml | A line holds between one and eight measures. Eight is a hard limit; this line has nine. |
line-number-out-of-order.txml | number must match the line position, counting from 1, and lines must appear in ascending order. |
line-repeat-duplicate-range.txml | Two line-repeat elements covering the identical range. Combine them into one with a higher times. |
line-repeat-first-after-last.txml | first must be less than or equal to last. |
line-repeat-last-exceeds-lines.txml | last must not exceed the number of lines in the section's content. |
line-repeat-partial-overlap.txml | Two line-repeat ranges must be properly nested or wholly disjoint. These partially overlap. |
line-repeat-times-one.txml | times="1" on a line-repeat. A range played once is not repeated, and a bare <line-repeat> already means twice. |
line-repeat-times-zero.txml | times must be an integer of 1 or greater. |
link-span-unclosed.txml | A link start must be closed by a stop within the same section reference. This span is left open at the end of the pass. |
link-spans-overlap.txml | A link start must be closed before another start appears. Link spans cannot nest or overlap. |
link-stop-without-start.txml | A link stop closes no open span. Every stop needs a start before it. |
lyric-measure-holds-note.txml | A lyric part's measures hold only syllable and rest. |
lyric-measure-holds-only-notes.txml | A lyric part's measures hold syllable and rest. This measure holds notes, which the grammar accepts on its own because a part's type is declared in <ensemble>, out of the grammar's reach. |
lyric-part-with-stack.txml | a lyric part carries stack. A stack is one instrument's own rows, and words are not a region of an instrument, so a lyric part sits beside a stack rather than joining one. |
measure-count-mismatch.txml | Corresponding lines must agree on measure count across parts. |
measure-number-mismatch.txml | number must match the measure position within its line, counting from 1. |
notated-measure-empty.txml | A measure in a pitched or unpitched part must hold at least one note, rest, or group. An empty one would be a measure of no beats. |
notated-measure-holds-syllable.txml | syllable is valid only in a lyric part. This part is pitched. |
note-pitch-and-sound.txml | pitch and sound are mutually exclusive. A note carries one or the other. |
note-without-pitch-or-sound.txml | A note must carry exactly one of pitch or sound. This one carries neither. |
parenthesis-dim-on-stop.txml | dim and mute describe the whole span, so they are valid only on a parenthesis start. |
parenthesis-span-unclosed.txml | A parenthesis start must be closed by a stop within the same section reference. |
part-data-duplicate-section-ref.txml | A part-data must not reference the same section twice. |
part-data-unresolved.txml | part-data references a part id that no part declares. |
part-stack-without-row.txml | stack and row must appear together. A part with stack must also carry row. |
part-type-invalid.txml | type must be pitched, unpitched, or lyric. |
part-without-part-data.txml | Every part must have exactly one part-data referencing it. P2 has none. |
pitch-double-modifier.txml | A pitch takes at most one Thai octave modifier. This one carries two nikhahit. |
pitch-unknown-letter.txml | pitch="H" is not one of the seven base notes in any of the three spellings. |
play-unresolved.txml | <play> names a section that no <section> declares. |
repeat-times-one.txml | times="1". A repeat that plays its content once is not a repeat, and the default is 2, so a document meaning "play once" writes no <repeat> at all. |
repeat-times-zero.txml | times must be an integer of 1 or greater. |
repeat-without-section.txml | A repeat must contain at least one section, directly or nested. This one wraps only an annotation. |
section-ref-unresolved.txml | section-ref references a section id that no section declares. |
section-ref-without-line.txml | A section-ref must hold at least one line. |
stack-not-adjacent.txml | A stack's parts must be adjacent in ensemble, in ascending row order. Another instrument sits between these two rows. |
stack-row-gap.txml | row values within one stack must run from 1 upward with no gaps. This stack has rows 1 and 3. |
stack-single-part.txml | A stack value must be shared by at least two parts. One part alone is a single-row instrument and carries neither attribute. |
text-align-invalid.txml | align accepts left, center, or right, and nothing else. |
text-duplicate-align.txml | A parent may hold at most one text child per align value. This annotation has two lefts. |
text-with-child-element.txml | text holds text and nothing else. Child elements are invalid. |
wrong-namespace.txml | The root is in a namespace no ThaiMusicXML 1.0 processor implements. |
Using the corpus
Section titled “Using the corpus”Point your implementation at both directories and compare its verdict to the directory name. That is the whole protocol.
A validator that accepts everything under valid/ and rejects everything under invalid/ agrees with this specification on every rule the corpus covers, which is every must in the Conformance reference. It says nothing about rendering, and nothing about the should rules, where a processor is expected to warn and carry on.
Where your reading and the corpus disagree, one of the two is wrong and it is worth finding out which. Open an issue rather than working around it: a rule that two people read differently is a rule stated badly, and the corpus exists to surface exactly that.