NAME
Music::NWC2MusicXML::MusicXML - Convert an internal Score object to a MusicXML 4.0 document string.
VERSION
0.001.1
SYNOPSIS
# --- Pattern 1: full pipeline from a .nwc file ---
use Music::NWC2MusicXML::NWC;
use Music::NWC2MusicXML::Parser;
use Music::NWC2MusicXML::MusicXML;
my $nwctxt = Music::NWC2MusicXML::NWC->read('my_score.nwc');
my $score = Music::NWC2MusicXML::Parser->new->parse($nwctxt);
my $xml = Music::NWC2MusicXML::MusicXML->new->generate($score);
# Write raw bytes -- the string is already pure ASCII (numeric entities
# for any non-ASCII source characters).
open my $fh, '>:raw', 'output.musicxml' or die $!;
print $fh $xml;
close $fh;
# --- Pattern 2: custom indentation ---
my $gen = Music::NWC2MusicXML::MusicXML->new(indent => "\t");
my $xml = $gen->generate($score);
# --- Pattern 3: validate the output with an external tool ---
# (run in the shell after writing the file)
# xmllint --noout output.musicxml
# --- Pattern 4: generate and keep in memory for further processing ---
my $xml_string = Music::NWC2MusicXML::MusicXML->new->generate($score);
my @lines = split /\n/, $xml_string;
my ($part_list) = grep { /part-list/ } @lines;
DESCRIPTION
Music::NWC2MusicXML::MusicXML is the last stage of the NWC-to-MusicXML pipeline. It takes a Music::NWC2MusicXML::Score object -- the internal representation built by Music::NWC2MusicXML::Parser -- and returns a self-contained MusicXML 4.0 document as a plain string.
The generator knows nothing about the NWCTXT or NWC binary format. Every musical decision (pitches, durations, articulations, dynamics, tempo, key, clef, copyright text) was already made by the parser. The generator only serialises the Score object tree into valid XML.
Divisions
MusicXML requires one integer, <divisions>, that says how many ticks equal one quarter note. To represent every note duration exactly -- including unusual tuplet values -- the generator scans all note durations across all staves, collects the denominators of their rational representations, and computes their least common multiple (LCM). That LCM becomes <divisions>. No duration is ever rounded or truncated.
Page layout and credits
The generator emits a <defaults> block that records the actual page size and margin values expressed in MusicXML tenths. Without this block a renderer cannot interpret the absolute coordinate values used by credit elements, so title and copyright placement would be undefined.
Page dimensions default to A4 (210 x 297 mm) with 1.27 cm margins, which match NWC's own defaults. If the source NWC file contained a PgMargins record the parser stores the margin values in the Score's page_setup hashref and the generator reads them from there.
Each title, subtitle, and copyright line is emitted as its own separate <credit> element:
Title --
<credit page="1">; large font; centred near the top of page 1.Subtitle (the NWC Author field) --
<credit page="1">; medium font; centred directly below the title.Each copyright line --
<credit>with no page attribute; this instructs conforming renderers to display the line on every page. One separate<credit>element is used per line; putting multiple<credit-words>inside a single<credit>causes many renderers to display only the last one.
Articulations
NWC encodes articulations as initial-capital tokens in the Dur: field (for example Tenuto, Staccato, Accent). The generator groups them into the correct MusicXML wrapper:
<articulations>-- tenuto, staccato, accent, strong-accent, staccatissimo.<ornaments>-- trill-mark, mordent, turn.Direct child of
<notations>-- fermata.
The Slur token is never emitted as an articulation; it is handled by the slur-annotation pre-pass (see "Slurs and ties" below).
Slurs and ties
A single pre-pass over all events in a staff (_annotate_events) detects slur arcs and tie pairs before the events are grouped into measures. This means arcs that cross a bar line are handled correctly. Each event is annotated with flags that the measure emitter reads when writing <slur> and <tied> elements.
Tie detection uses the raw NWC position string as a key. A ^ suffix on a position string (e.g. Pos:-7^) means the note is tied forward; the generator strips the suffix to match the tied-to note.
Hairpins (wedges)
NWC does not use standalone records for in-staff hairpins. Instead it attaches Opts:Crescendo or Opts:Diminuendo to every note and rest that sits under the arc. A second pre-pass (_annotate_wedges) detects the start and end of each arc by watching for transitions: the first event carrying a hairpin flag starts the arc; the first event that drops the flag closes it. The wedge stop is emitted as a crescOff direction immediately after the last note of the arc.
Tempo variance
Markings such as Accelerando, Ritardando, Rallentando, and RitardandoToTempo (rendered as "a tempo") are stored as TempoVariance events by the parser. The generator converts them to italic <words> direction elements using the %TEMPO_VARIANCE_TEXT table.
Part name resolution
Display names for each staff are resolved in three steps:
- 1. Use the NWC staff name, if it is not a generic default such as
StafforStaff-2. - 2. Fall back to the MIDI instrument name.
- 3. Fall back to the positional name
Staff-N(1-based).
After all candidates are found, any name that appears on more than one staff is replaced with Staff-N to guarantee unique <part-name> values.
COMMON PITFALLS
- Opening the output file in text mode
-
The output string is pure ASCII (all non-ASCII characters are escaped as numeric XML entities). Opening the output file with
'>:encoding(UTF-8)'is harmless but unnecessary; opening it with'>:encoding(Latin-1)'or a similar 8-bit encoding and then printing a string that contains non-ASCII bytes would corrupt the file. The safest choice is'>:raw'. - Multiple copyright lines in one credit element
-
If you call
_emit_creditsand place two<credit-words>children inside a single<credit>element, most renderers (including MuseScore and Finale) display only the last<credit-words>and silently discard the rest. This module uses one<credit>per copyright line to avoid this. - Missing defaults section
-
The absolute coordinates in
<credit>elements (default-x,default-y) are measured in MusicXML "tenths" from the bottom-left corner of the page. They are meaningless to a renderer unless a<defaults>block defines the page size and the tenths-per-mm scaling factor. This module always emits<defaults>before any<credit>elements. - Two separate rights elements in identification
-
<identification>accepts only one<rights>child in practice; a second one shadows the first. This module joins multiple copyright lines with a newline character inside a single<rights>element. - Slur token treated as an articulation
-
NWC encodes slurs as
Slurin the same token list as articulations. Do not addSlurto%ARTICULATION_MAP; the slur-annotation pre-pass handles it. IfSlurwere also emitted as an articulation element the output XML would be invalid. - Score with no staves
-
Calling
generateon aScoreobject that has no staves willcroakimmediately. Always check that the parser produced at least one staff before calling the generator.
ENCODING
- Input (Score metadata fields)
-
Metadata strings (Title, Author, Copyright1, Copyright2, etc.) may contain any Unicode characters, including non-ASCII letters, accented characters, and the copyright symbol (U+00A9). The NWC binary decoder may deliver these as Latin-1 bytes; once stored in Perl scalars they are handled correctly as long as they pass through
_xml_escapebefore being written to the output. - Output (the generated XML string)
-
The string returned by
generateis pure 7-bit ASCII. Every character whose code point is above 127 is converted to a numeric XML character reference (&#N;), for example©for the copyright symbol. The XML declaration at the top of the document readsencoding="UTF-8", which remains correct because numeric character references are valid in any XML encoding. - Emojis and full Unicode
-
Emoji and supplementary-plane characters (code points above U+FFFF) are not tested but will be escaped correctly by
_xml_escapeas long as Perl has decoded them to proper Unicode code points (i.e.utf8::decodehas been applied or the string was read with a:utf8layer). Raw multi-byte UTF-8 bytes that have not been decoded will be escaped byte-by-byte and will produce incorrect numeric references.
new
Create a new generator object.
Purpose
Factory constructor. Creates a configured generator ready to call generate one or more times. The same generator instance can be used to process multiple Score objects; each generate call is independent.
Arguments
All arguments are named (passed as a flat key/value list) and optional.
indent-
The string used for one level of XML indentation. Defaults to two spaces. Pass
"\t"for tab indentation. Only affects whitespace; the XML content is identical regardless of the indent setting. diagnostics-
A
Music::NWC2MusicXML::Diagnosticsinstance for routing warning messages. When omitted, warnings are sent directly tocarp.
Returns
A blessed Music::NWC2MusicXML::MusicXML object.
Side Effects
None.
Usage Example
# Default (two-space indent)
my $gen = Music::NWC2MusicXML::MusicXML->new;
# Tab indent
my $gen = Music::NWC2MusicXML::MusicXML->new(indent => "\t");
API SPECIFICATION
Input
indent : SCALAR (optional, default ' ')
diagnostics : OBJECT Music::NWC2MusicXML::Diagnostics (optional)
Output
Music::NWC2MusicXML::MusicXML object
MESSAGES
This method does not emit any diagnostic messages.
generate
Convert a Music::NWC2MusicXML::Score object to a MusicXML 4.0 document and return the complete document as a string.
Purpose
Top-level entry point. Orchestrates, in order:
- 1. XML declaration and DOCTYPE header.
- 2. Page layout geometry (
<defaults>). - 3. Work title (
<work>). - 4. Identification metadata: composer, lyricist, rights (
<identification>). - 5. Visual credits: title, subtitle, and copyright lines (
<credit>elements). - 6. Part list: one
<score-part>per staff (<part-list>). - 7. Musical content: one
<part>per staff, each containing numbered measures with notes, rests, dynamics, tempo, articulations, slurs, ties, and wedge hairpins.
Each staff is processed by a two-stage pipeline:
- Stage 1 -- annotation pre-passes.
-
_annotate_eventsdetects slur arcs and tie pairs across the entire staff before measure grouping._annotate_wedgesdetects hairpin (crescendo / diminuendo) arcs stored as per-noteOpts:flags. Both passes store their results in a shared%annhash keyed by stringified event reference. - Stage 2 -- measure emission.
-
Events are gathered into measure-sized groups separated by
Barevents. For each measure,_emit_measureserialises notes, rests, directions, and mid-staff attribute changes, consulting%annfor slur, tie, and wedge annotations.
Arguments
$score-
A
Music::NWC2MusicXML::Scoreobject (required). Must contain at least one staff; otherwise the method croaks.
Returns
A scalar string holding the complete MusicXML 4.0 document. The string is pure 7-bit ASCII: all non-ASCII source characters are replaced with numeric XML character references (&#N;). The XML declaration at the top of the string declares encoding="UTF-8", which is correct.
The string ends with a single newline character.
Side Effects
May issue warnings via carp for unsupported or unrecognised values (unknown clef, unknown dynamic marking, unsupported articulation token, approximate barline style).
Usage Example
my $xml = $gen->generate($score);
# Write to a file -- ':raw' is sufficient; the string is pure ASCII.
open my $fh, '>:raw', 'out.musicxml' or die $!;
print $fh $xml;
close $fh;
API SPECIFICATION
Input
$score : Music::NWC2MusicXML::Score (required, must have staff_count > 0)
Output
SCALAR -- complete MusicXML 4.0 document, pure 7-bit ASCII, newline-terminated
MESSAGES
| Code | Meaning | Resolution | |---------------------|------------------------------------------|---------------------------------| | error_bad_score | Argument is not a Score object | Pass the object returned by Parser | | error_no_staves | Score has zero staves | Confirm the parser found AddStaff records | | warn_unknown_clef | NWC clef name not in CLEF_MAP | Treble used as fallback | | warn_unknown_dynamic| Dynamic marking not in DYNAMIC_MAP | Direction element omitted | | warn_unsupported_art| Articulation token not in ARTICULATION_MAP| Mark omitted; warning issued | | warn_approx_bar | Barline style has no direct MusicXML map | Regular barline used |
DIAGNOSTICS
The module uses croak for unrecoverable errors and carp for warnings that allow generation to continue. All messages are looked up in the %MESSAGES constant at the top of the file so that the strings are easy to find and change without searching the whole source.
Fatal errors (croak)
error_bad_score-
generatewas called with an argument that is not aMusic::NWC2MusicXML::Scoreobject. Resolution: pass the Score object returned byMusic::NWC2MusicXML::Parser->parse. error_no_staves-
The Score object has zero staves. Generation cannot proceed without at least one staff. Resolution: confirm that the parser found one or more
AddStaffrecords in the NWCTXT input.
Warnings (carp)
warn_unknown_clef-
An NWC clef name was encountered that is not present in
%CLEF_MAP(currently: Treble, Bass, Alto, Tenor, Percussion, Tab). Resolution: Treble is used as a fallback. The output is playable but visually incorrect for that staff. warn_unknown_dynamic-
A dynamic marking was encountered that is not in
%DYNAMIC_MAP(currently: pppp ppp pp p mp mf f ff fff ffff). Resolution: the direction element is omitted; the surrounding music is unaffected. warn_unsupported_art-
An articulation token from the NWC
Dur:field is not present in%ARTICULATION_MAP. Resolution: the mark is omitted from that note; the note itself is still emitted correctly. warn_approx_bar-
A barline style has no direct MusicXML equivalent. Resolution: a regular barline is used.
LIMITATIONS
Tuplet
<time-modification>and<tuplet>notation elements are not yet emitted. Tuplet durations are stored correctly as rational numbers in the Score, so the audio timing is right, but the printed notation will show normal note values rather than tuplet brackets.Multi-voice staves (two melodic lines on one staff) assign all events to MusicXML voice 1. True voice splitting -- two simultaneous streams with independent stems -- is deferred to a future release.
Slur numbering: all slurs use number 1. If more than one slur arc is open simultaneously (which is rare but legal in NWC), the overlapping slurs will share the same number and the output will be invalid. The constant
$MAX_SLUR_NUMBERdocuments the intended limit.Lyric text (
Lyricevents) is not yet serialised. The events are parsed and stored in the Score but no<lyric>elements appear in the output.Flow-control directives (Coda, Segno, DaCapo, Volta brackets, etc.) are stored as
FlowControlevents by the parser but produce no MusicXML output.Page dimensions are always assumed to be A4 (210 x 297 mm). NWC supports custom page sizes through
PgSetupfields that are not yet read by the parser.Only uniform margins are supported. NWC allows different left, right, top, and bottom margins, and also supports mirrored margins for left/right pages. The generator uses only the left margin value and applies it to all four sides.
AUTHOR
Nigel Horne <nigel.horne@gmail.com>
LICENSE AND COPYRIGHT
This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.
Copyright (C) 2025 Nigel Horne.
FORMAL SPECIFICATION
This section uses Z-notation-inspired schemas to describe the state transformations performed by the key methods. The notation is informal; its purpose is to make the invariants and pre/post-conditions precise enough for future verification or reimplementation.
Mathematical sets used below:
Score -- the internal score object type
Staff -- a single staff within a Score
Event -- a single musical or structural event
XML -- a well-formed XML document string (7-bit ASCII)
Tenths -- a non-negative real number representing a MusicXML tenths value
Q+ -- the set of non-negative rational numbers
Z -- the set of integers
new
GeneratorInit
___________________________
indent? : String
diagnostics? : Diagnostics
___________________________
gen! : MusicXMLGenerator
Pre: indent? in String (any string, default ' ')
Post: gen!._indent = indent? | ' '
gen!._diagnostics = diagnostics? | undef
No I/O side effects.
generate
Generate
___________________________
gen : MusicXMLGenerator
score : Score
___________________________
xml! : XML
Pre: score.staff_count > 0
Pre: score.isa('Music::NWC2MusicXML::Score')
Let D = lcm { ev.duration.denominator | ev in events(score) }
Let L = compute_page_layout(score.page_setup)
Post: xml! is a well-formed MusicXML 4.0 document string
Post: xml! contains exactly one <defaults> block with
page_height = 297.0 * TENTHS_PER_MM (A4)
page_width = 210.0 * TENTHS_PER_MM
margin = score.page_setup.Left * 10 * TENTHS_PER_MM
| DEFAULT_MARGIN_CM * 10 * TENTHS_PER_MM
Post: xml! contains one <score-part> per staff in score order
Post: xml! contains one <part> per staff
Post: for every note event ev in score,
tick_count(ev) = round(ev.duration[0] * D / ev.duration[1])
-- no duration is lost or rounded by more than 0.5 ticks
_annotate_events
AnnotateSlursTies
___________________________
events : seq Event
___________________________
ann! : Map(EventRef -> Annotation)
Let sounding = { ev in events | ev.type in {Note, Rest, Chord} }
Post: forall ev in sounding,
ev.data.nwc_pos ends with '^'
=> ann!(ev).tie_start_keys contains stripped_key(ev.data.nwc_pos)
Post: every tie_start has a matching tie_stop on the next sounding event
with the same stripped position key (or the arc is left open at
end-of-staff)
Post: slur_start is set on the first event of each uninterrupted run of
events carrying the 'Slur' articulation token
Post: slur_stop is set on the last event of each such run
Post: Rest events are included in slur spans but carry neither
slur_start nor slur_stop
_annotate_wedges
AnnotateWedges
___________________________
events : seq Event
ann : Map(EventRef -> Annotation) -- pre-existing, mutated in place
___________________________
ann! : Map(EventRef -> Annotation) -- same map, extended
Let sounding = { ev in events | ev.type in {Note, Rest, Chord} }
Post: forall consecutive pairs (e1, e2) in sounding,
e1.data.opts.Crescendo and not e2.data.opts.Crescendo
=> ann!(e1).wedge_stop_after = 1
e1.data.opts.Diminuendo and not e2.data.opts.Diminuendo
=> ann!(e1).wedge_stop_after = 1
not e1.data.opts.Crescendo and e2.data.opts.Crescendo
=> ann!(e2).wedge_start = 'Crescendo'
not e1.data.opts.Diminuendo and e2.data.opts.Diminuendo
=> ann!(e2).wedge_start = 'Diminuendo'
Post: if the last sounding event carries a hairpin flag,
ann!(last).wedge_stop_after = 1
_compute_page_layout
ComputePageLayout
___________________________
page_setup : HashRef -- from Score.page_setup (may be empty)
___________________________
layout! : HashRef
Let margin_cm = page_setup.Left | DEFAULT_MARGIN_CM (1.27 cm)
Let margin_t = margin_cm * 10 * (TENTHS_PER_SPACE / MM_PER_SPACE)
Post: layout!.page_height = 297.0 * (TENTHS_PER_SPACE / MM_PER_SPACE)
Post: layout!.page_width = 210.0 * (TENTHS_PER_SPACE / MM_PER_SPACE)
Post: layout!.margin_t = margin_t
Post: layout!.center_x = layout!.page_width / 2
Post: layout!.right_x = layout!.page_width - margin_t
Post: function is pure (no I/O, no state mutation)
_pos_to_pitch
PosToPitch
___________________________
pos_str : String -- NWC position string, e.g. '#-6', 'b3', '-9^'
clef : String -- NWC clef name, e.g. 'Treble', 'Bass'
key_fifths : Z -- circle-of-fifths integer (-7 .. 7)
___________________________
pitch! : HashRef { step, octave, alter, accidental? }
Let (acc_prefix, pos_num) = parse(pos_str)
Let (ref_oct, ref_step) = CLEF_REF[clef]
Let index = ref_oct * 7 + ref_step + pos_num
Let octave = floor(index / 7)
Let step_i = index - octave * 7 -- in 0..6
Let step = STEP_NAMES[step_i] -- in {C,D,E,F,G,A,B}
Post: pitch!.step = step
Post: pitch!.octave = octave
Post: acc_prefix = '' => pitch!.alter = key_alter_for_step(step_i, key_fifths)
pitch!.accidental = undef
acc_prefix = '#' => pitch!.alter = 1, pitch!.accidental = 'sharp'
acc_prefix = 'b' => pitch!.alter = -1, pitch!.accidental = 'flat'
acc_prefix = 'n' => pitch!.alter = 0, pitch!.accidental = 'natural'
acc_prefix = 'x'
or acc_prefix = '##' => pitch!.alter = 2, pitch!.accidental = 'double-sharp'
acc_prefix = 'bb' => pitch!.alter = -2, pitch!.accidental = 'double-flat'