Skip to content

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:

Terminal window
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.

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.

FileWhat it exercises
aligned-text.txmlAligned 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.txmlA 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.txmlA 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.txmlOptional ids on line, measure, note, rest, and group, plus a foreign-namespace extension element after the last <part-data>.
line-annotation.txmlAnnotations 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.txmlLine 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.txmlA 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.txmlA 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.txmlA 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.txmlA 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.txmlThe smallest legal ThaiMusicXML document: one part, one section, one line, one four-beat measure.
pitch-spellings.txmlThe 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.txmlNested 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.txmlRight-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.txmlA 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.txmlABA 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.txmlBow 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.txmlA 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.txmlAn 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.txmlA 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.txmlA 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.

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.

FileWhat it violates
beat-count-mismatch.txmlCorresponding measures must agree on beat count across parts. P1 has four beats where P2 has three.
bow-span-unclosed.txmlA 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.txmlA bow start must be closed before another start appears. Bow spans cannot nest or overlap.
bow-start-without-direction.txmldirection is required on a bow start.
bow-stop-with-direction.txmldirection must not appear on a bow stop. The span already carries its direction from the start.
bow-stop-without-start.txmlA bow stop with no open span before it.
bpm-zero.txmlbpm content must be a positive integer.
chan-value-not-exact.txmlchan is a closed enumeration matched exactly. "0.50" is not "0.5".
duplicate-note-id.txmlTwo 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.txmlTwo parts share an id. Ids must be unique within their kind.
duplicate-section-id.txmlTwo sections share an id.
ending-beat-count-differs.txmlCorresponding 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.txmlTwo endings must not cover the same line number for the same pass.
ending-line-not-trailing.txmlAn 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.txmlEach 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.txmlAn ending line must have the same number of measures as the line it replaces.
ending-on-unrepeated-section.txmlAn ending is only valid in a section whose total pass count is greater than 1. This section plays once.
ending-pass-descending.txmlpass values must be listed in ascending order with no repeats.
ending-pass-exceeds-total.txmlpass values must fall within the section's total pass count, which is 2 here.
ending-pass-zero.txmlpass numbers are 1-based. Zero is not a pass.
ending-without-annotation.txmlAn 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.txmlAn extension element after <part-data> with no namespace of its own, indistinguishable from native markup.
group-nested.txmlGroups do not nest. Subdivisions in v1.0 go one level deep.
group-one-child.txmlA group must contain at least two note or rest children. There is nothing to subdivide a beat between here.
header-out-of-order.txmlheader children are ordered. title comes first.
id-with-whitespace.txmlan 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.txmla <line>'s annotations come before its measures, the same order <section-ref> uses. One sitting between two measures is rejected.
line-count-mismatch.txmlAll section-refs for one section must agree on line count.
line-nine-measures.txmlA line holds between one and eight measures. Eight is a hard limit; this line has nine.
line-number-out-of-order.txmlnumber must match the line position, counting from 1, and lines must appear in ascending order.
line-repeat-duplicate-range.txmlTwo line-repeat elements covering the identical range. Combine them into one with a higher times.
line-repeat-first-after-last.txmlfirst must be less than or equal to last.
line-repeat-last-exceeds-lines.txmllast must not exceed the number of lines in the section's content.
line-repeat-partial-overlap.txmlTwo line-repeat ranges must be properly nested or wholly disjoint. These partially overlap.
line-repeat-times-one.txmltimes="1" on a line-repeat. A range played once is not repeated, and a bare <line-repeat> already means twice.
line-repeat-times-zero.txmltimes must be an integer of 1 or greater.
link-span-unclosed.txmlA 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.txmlA link start must be closed before another start appears. Link spans cannot nest or overlap.
link-stop-without-start.txmlA link stop closes no open span. Every stop needs a start before it.
lyric-measure-holds-note.txmlA lyric part's measures hold only syllable and rest.
lyric-measure-holds-only-notes.txmlA 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.txmla 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.txmlCorresponding lines must agree on measure count across parts.
measure-number-mismatch.txmlnumber must match the measure position within its line, counting from 1.
notated-measure-empty.txmlA 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.txmlsyllable is valid only in a lyric part. This part is pitched.
note-pitch-and-sound.txmlpitch and sound are mutually exclusive. A note carries one or the other.
note-without-pitch-or-sound.txmlA note must carry exactly one of pitch or sound. This one carries neither.
parenthesis-dim-on-stop.txmldim and mute describe the whole span, so they are valid only on a parenthesis start.
parenthesis-span-unclosed.txmlA parenthesis start must be closed by a stop within the same section reference.
part-data-duplicate-section-ref.txmlA part-data must not reference the same section twice.
part-data-unresolved.txmlpart-data references a part id that no part declares.
part-stack-without-row.txmlstack and row must appear together. A part with stack must also carry row.
part-type-invalid.txmltype must be pitched, unpitched, or lyric.
part-without-part-data.txmlEvery part must have exactly one part-data referencing it. P2 has none.
pitch-double-modifier.txmlA pitch takes at most one Thai octave modifier. This one carries two nikhahit.
pitch-unknown-letter.txmlpitch="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.txmltimes="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.txmltimes must be an integer of 1 or greater.
repeat-without-section.txmlA repeat must contain at least one section, directly or nested. This one wraps only an annotation.
section-ref-unresolved.txmlsection-ref references a section id that no section declares.
section-ref-without-line.txmlA section-ref must hold at least one line.
stack-not-adjacent.txmlA stack's parts must be adjacent in ensemble, in ascending row order. Another instrument sits between these two rows.
stack-row-gap.txmlrow values within one stack must run from 1 upward with no gaps. This stack has rows 1 and 3.
stack-single-part.txmlA 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.txmlalign accepts left, center, or right, and nothing else.
text-duplicate-align.txmlA parent may hold at most one text child per align value. This annotation has two lefts.
text-with-child-element.txmltext holds text and nothing else. Child elements are invalid.
wrong-namespace.txmlThe root is in a namespace no ThaiMusicXML 1.0 processor implements.

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.