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 Staff or Staff-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'.

If you call _emit_credits and 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 Slur in the same token list as articulations. Do not add Slur to %ARTICULATION_MAP; the slur-annotation pre-pass handles it. If Slur were also emitted as an articulation element the output XML would be invalid.

Score with no staves

Calling generate on a Score object that has no staves will croak immediately. 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_escape before being written to the output.

Output (the generated XML string)

The string returned by generate is pure 7-bit ASCII. Every character whose code point is above 127 is converted to a numeric XML character reference (&#N;), for example &#169; for the copyright symbol. The XML declaration at the top of the document reads encoding="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_escape as long as Perl has decoded them to proper Unicode code points (i.e. utf8::decode has been applied or the string was read with a :utf8 layer). 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::Diagnostics instance for routing warning messages. When omitted, warnings are sent directly to carp.

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>).
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_events detects slur arcs and tie pairs across the entire staff before measure grouping. _annotate_wedges detects hairpin (crescendo / diminuendo) arcs stored as per-note Opts: flags. Both passes store their results in a shared %ann hash keyed by stringified event reference.

Stage 2 -- measure emission.

Events are gathered into measure-sized groups separated by Bar events. For each measure, _emit_measure serialises notes, rests, directions, and mid-staff attribute changes, consulting %ann for slur, tie, and wedge annotations.

Arguments

$score

A Music::NWC2MusicXML::Score object (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

generate was called with an argument that is not a Music::NWC2MusicXML::Score object. Resolution: pass the Score object returned by Music::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 AddStaff records 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_NUMBER documents the intended limit.

  • Lyric text (Lyric events) 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 FlowControl events 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 PgSetup fields 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'