File Coverage

File:blib/lib/Music/NWC2MusicXML/MusicXML.pm
Coverage:83.3%

linestmtbrancondsubtimecode
1package Music::NWC2MusicXML::MusicXML;
2
3
14
14
14
28843
5
94
use strict;
4
14
14
14
13
12
168
use warnings;
5
14
14
14
150
3415
25
use autodie qw(:all);
6
7our $VERSION = '0.002.4';
8
9
14
14
14
21280
9
202
use Carp qw(croak carp);
10
14
14
14
13
6
40
use POSIX qw(floor);
11
14
14
14
392
888
118
use Readonly;
12
14
14
14
11
8
223
use List::Util qw(any);
13
14
14
14
18
5
113
use Scalar::Util qw(blessed);
14
14
14
14
257
7736
112
use Params::Validate::Strict qw(validate_strict);
15
14
14
14
151
1851
96
use Params::Get;
16
14
14
14
212
7
76
use Music::NWC2MusicXML::Score;
17
14
14
14
12
5
37863
use Music::NWC2MusicXML::Event;
18
19# ---------------------------------------------------------------------------
20# MusicXML structural constants
21# ---------------------------------------------------------------------------
22
23# MusicXML version targeted (supported by MuseScore 3+)
24Readonly::Scalar my $MUSICXML_VERSION => '4.0';
25
26# DOCTYPE public/system identifiers
27Readonly::Scalar my $DOCTYPE_PUBLIC =>
28        '-//Recordare//DTD MusicXML 4.0 Partwise//EN';
29Readonly::Scalar my $DOCTYPE_SYSTEM =>
30        'http://www.musicxml.org/dtds/partwise.dtd';
31
32# Default divisions per quarter note used as a fallback; the actual value is
33# computed per-score from the LCM of all note-duration denominators.
34Readonly::Scalar my $DEFAULT_DIVISIONS => 24;
35
36# Maximum number of simultaneous open slurs per staff
37Readonly::Scalar my $MAX_SLUR_NUMBER => 6;
38
39# ---------------------------------------------------------------------------
40# Clef mappings: NWC clef name -> { sign, line, [clef-octave-change] }
41# ---------------------------------------------------------------------------
42Readonly::Hash my %CLEF_MAP => (
43        Treble     => { sign => 'G', line => 2 },
44        Bass       => { sign => 'F', line => 4 },
45        Alto       => { sign => 'C', line => 3 },
46        Tenor      => { sign => 'C', line => 4 },
47        Percussion => { sign => 'percussion' },
48        Tab        => { sign => 'TAB' },
49);
50
51# ---------------------------------------------------------------------------
52# Articulation mappings: NWC token (initial-cap) -> { element, group }
53# group: 'articulations' | 'ornaments' | 'direct' (emitted directly in <notations>)
54Readonly::Hash my %ARTICULATION_MAP => (
55        Tenuto        => { element => 'tenuto',        group => 'articulations' },
56        Staccato      => { element => 'staccato',      group => 'articulations' },
57        Accent        => { element => 'accent',        group => 'articulations' },
58        Marcato       => { element => 'strong-accent', group => 'articulations' },
59        Staccatissimo => { element => 'staccatissimo', group => 'articulations' },
60        Fermata       => { element => 'fermata',       group => 'direct'        },
61        Trill         => { element => 'trill-mark',    group => 'ornaments'     },
62        Mordent       => { element => 'mordent',       group => 'ornaments'     },
63        Turn          => { element => 'turn',          group => 'ornaments'     },
64);
65
66# ---------------------------------------------------------------------------
67# Dynamic markings: NWC -> MusicXML element
68# ---------------------------------------------------------------------------
69Readonly::Hash my %DYNAMIC_MAP => map { $_ => $_ }
70        qw(pppp ppp pp p mp mf f ff fff ffff);
71
72# ---------------------------------------------------------------------------
73# Tempo base unit: NWC Base field -> { unit, dot, quarter_factor }
74# quarter_factor converts noted BPM to quarter-note BPM for <sound tempo="..."/>
75# ---------------------------------------------------------------------------
76Readonly::Hash my %TEMPO_BASE_MAP => (
77        'Whole'              => { unit => 'whole',   dot => 0, factor => 4    },
78        'Half'               => { unit => 'half',    dot => 0, factor => 2    },
79        'Quarter'            => { unit => 'quarter', dot => 0, factor => 1    },
80        'Eighth'             => { unit => 'eighth',  dot => 0, factor => 0.5  },
81        'Sixteenth'          => { unit => '16th',    dot => 0, factor => 0.25 },
82        'Dotted Whole'       => { unit => 'whole',   dot => 1, factor => 6    },
83        'Dotted Half'        => { unit => 'half',    dot => 1, factor => 3    },
84        'Dotted Quarter'     => { unit => 'quarter', dot => 1, factor => 1.5  },
85        'Dotted Eighth'      => { unit => 'eighth',  dot => 1, factor => 0.75 },
86        'Dotted Sixteenth'   => { unit => '16th',    dot => 1, factor => 0.375 },
87);
88
89# ---------------------------------------------------------------------------
90# Pitch-conversion constants
91# ---------------------------------------------------------------------------
92
93# Reference note (step_index, octave) for NWC position 0 = middle line (3rd from bottom) of each staff.
94# Step indices: C=0, D=1, E=2, F=3, G=4, A=5, B=6
95# Verified against Pilgrim.nwc: Bass pos -7 = A2 (A-drone), Treble pos -9 = D4 (tonic).
96Readonly::Hash my %CLEF_REF => (
97        Treble     => [ 6, 4 ],   # B4 (middle line)
98        Bass       => [ 1, 3 ],   # D3 (middle line)
99        Alto       => [ 0, 4 ],   # C4 (middle line)
100        Tenor      => [ 5, 3 ],   # A3 (middle line)
101        Percussion => [ 6, 4 ],   # B4 (treat as treble)
102        Tab        => [ 6, 4 ],   # B4 (treat as treble)
103);
104
105# Diatonic step names indexed 0-6
106Readonly::Array my @STEP_NAMES => qw(C D E F G A B);
107
108# Circle-of-fifths order: sharps = F C G D A E B; flats = B E A D G C F
109Readonly::Array my @SHARP_STEPS => ( 3, 0, 4, 1, 5, 2, 6 );
110Readonly::Array my @FLAT_STEPS  => ( 6, 2, 5, 1, 4, 0, 3 );
111
112# NWC accidental prefix -> [ semitone_alter, MusicXML accidental name ]
113# P1: empty prefix and unknown prefixes fall back to key-signature alter (no accidental element).
114# P2: 'x' is NWC's alias for '##' (double-sharp written as a cross).
115Readonly::Hash my %ACCIDENTAL_MAP => (
116        '#'  => [  1, 'sharp'        ],
117        '##' => [  2, 'double-sharp' ],
118        'x'  => [  2, 'double-sharp' ],
119        'b'  => [ -1, 'flat'         ],
120        'bb' => [ -2, 'double-flat'  ],
121        'n'  => [  0, 'natural'      ],
122);
123
124# NWC base-duration name -> MusicXML type string
125Readonly::Hash my %NWC_TYPE_MAP => (
126        Whole  => 'whole',
127        Half   => 'half',
128        '4th'  => 'quarter',
129        '8th'  => 'eighth',
130        '16th' => '16th',
131        '32nd' => '32nd',
132        '64th' => '64th',
133);
134
135# Flow-control directive -> MusicXML element and/or words direction
136Readonly::Hash my %FLOW_CONTROL_MAP => (
137        Coda           => { element => 'coda'                      },
138        Segno          => { element => 'segno'                     },
139        DaCapo         => { words => 'D.C.'                        },
140        DaCapoAlFine   => { words => 'D.C. al Fine'                },
141        DaCapoAlCoda   => { words => 'D.C. al Coda'                },
142        DalSegno       => { words => 'D.S.'                        },
143        DalSegnoAlFine => { words => 'D.S. al Fine'                },
144        DalSegnoAlCoda => { words => 'D.S. al Coda'                },
145        Fine           => { words => 'Fine'                        },
146        ToCoda         => { element => 'coda', words => 'To Coda'  },
147);
148
149Readonly::Hash my %MESSAGES => (
150        error_bad_score      => 'generate: argument must be a Music::NWC2MusicXML::Score',
151        error_no_staves      => 'Score contains no staves -- cannot generate MusicXML',
152        error_write_failed   => 'Cannot write to output: %s',
153        error_internal       => 'Internal error: %s',
154        warn_unknown_clef    => 'Unrecognised NWC clef %s -- defaulting to Treble',
155        warn_unknown_dynamic => 'Unrecognised dynamic marking %s',
156        warn_unsupported_art => 'Unsupported articulation %s',
157        warn_approx_bar      => 'Bar style %s approximated as regular',
158);
159
160 - 434
=head1 NAME

Music::NWC2MusicXML::MusicXML - Convert an internal Score object to a MusicXML 4.0 document string.

=head1 VERSION

0.002.4

=head1 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;

=head1 DESCRIPTION

C<Music::NWC2MusicXML::MusicXML> is the last stage of the NWC-to-MusicXML
pipeline.  It takes a C<Music::NWC2MusicXML::Score> object -- the internal
representation built by C<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.

=head2 Divisions

MusicXML requires one integer, C<< <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
C<< <divisions> >>.  No duration is ever rounded or truncated.

=head2 Page layout and credits

The generator emits a C<< <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 C<PgMargins>
record the parser stores the margin values in the Score's C<page_setup>
hashref and the generator reads them from there.

Each title, subtitle, and copyright line is emitted as its own separate
C<< <credit> >> element:

=over 4

=item * Title -- C<< <credit page="1"> >>; large font; centred near the top
of page 1.

=item * Subtitle (the NWC Author field) -- C<< <credit page="1"> >>; medium
font; centred directly below the title.

=item * Each copyright line -- C<< <credit> >> with B<no page attribute>;
this instructs conforming renderers to display the line on B<every> page.
One separate C<< <credit> >> element is used per line; putting multiple
C<< <credit-words> >> inside a single C<< <credit> >> causes many renderers
to display only the last one.

=back

=head2 Articulations

NWC encodes articulations as initial-capital tokens in the C<Dur:> field
(for example C<Tenuto>, C<Staccato>, C<Accent>).  The generator groups them
into the correct MusicXML wrapper:

=over 4

=item * C<< <articulations> >> -- tenuto, staccato, accent, strong-accent,
staccatissimo.

=item * C<< <ornaments> >> -- trill-mark, mordent, turn.

=item * Direct child of C<< <notations> >> -- fermata.

=back

The C<Slur> token is never emitted as an articulation; it is handled by the
slur-annotation pre-pass (see L</Slurs and ties> below).

=head2 Slurs and ties

A single pre-pass over all events in a staff (C<_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
C<< <slur> >> and C<< <tied> >> elements.

Tie detection uses the raw NWC position string as a key.  A C<^> suffix on a
position string (e.g. C<Pos:-7^>) means the note is tied forward; the
generator strips the suffix to match the tied-to note.

=head2 Dynamic variance (text markings)

NWC C<DynVariance> (also exported as C<DynamicVariance> by some NWC versions)
records represent written-out dynamic variance text — not hairpins.
C<Style:Crescendo> produces C<< <words font-style="italic">cresc.</words> >>;
C<Style:Diminuendo> produces C<< <words font-style="italic">dim.</words> >>;
C<Style:crescOff> (the end-of-phrase marker for an extended text cresc.) emits
nothing, since MusicXML text directions require no explicit stop element.

Do not confuse these with hairpin crescendo/diminuendo wedges: those come from
C<Opts:Crescendo> / C<Opts:Diminuendo> attached to individual note or rest
events (see L</Hairpins (wedges)> below).

=head2 Hairpins (wedges)

NWC does not use standalone records for in-staff hairpins.  Instead it
attaches C<Opts:Crescendo> or C<Opts:Diminuendo> to every note and rest that
sits under the arc.  A second pre-pass (C<_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 C<crescOff> direction immediately
after the last note of the arc.

=head2 Tempo marks

A C<Tempo> event carries three data keys: C<bpm> (integer BPM), C<base> (NWC
beat-unit string such as C<'Quarter'> or C<'Dotted Half'>), and C<text>
(optional free-text string from the NWC C<Text:> field, e.g.
C<'With Expression'>; empty string when absent).

When C<text> is non-empty, C<_emit_tempo> emits it as a
C<< <words font-style="italic"> >> C<< <direction-type> >> block before the
C<< <metronome> >> block, both within the same C<< <direction> >> element.
The metronome element uses C<parentheses="yes"> in this case so renderers
display the marking as e.g. I<With Expression (crotchet = 92)>.  When C<text>
is empty only the metronome block is emitted and C<parentheses="no"> is used.

=head2 Tempo variance

Markings such as C<Accelerando>, C<Ritardando>, C<Ritenuto>, C<Rallentando>,
and C<RitardandoToTempo> (rendered as "a tempo") are stored as
C<TempoVariance> events by the parser.  The generator converts them to italic
C<< <words> >> direction elements using the C<%TEMPO_VARIANCE_TEXT> table.
Both C<Ritardando> and C<Ritenuto> abbreviate to "rit.".

The C<Style:Fermata> value is a special case: it is not a tempo-change
direction but a symbol placed over the note or rest that immediately follows
the C<TempoVariance> record.  C<_annotate_fermatas> detects these records in
a pre-pass and marks the following sounding event so that C<_emit_note_event>
and C<_emit_rest_event> emit C<< <notations><fermata/></notations> >> on it.
The C<TempoVariance:Fermata> event itself is then skipped during measure
emission and never produces a C<< <direction> >> element.

=head2 Part name resolution

Display names for each staff are resolved in three steps:

=over 4

=item 1. Use the NWC staff name, if it is not a generic default such as
C<Staff> or C<Staff-2>.

=item 2. Fall back to the MIDI instrument name.

=item 3. Fall back to the positional name C<Staff-N> (1-based).

=back

After all candidates are found, any name that appears on more than one staff
is replaced with C<Staff-N> to guarantee unique C<< <part-name> >> values.

=head1 COMMON PITFALLS

=over 4

=item B<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 C<< '>:encoding(UTF-8)' >>
is harmless but unnecessary; opening it with C<< '>: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 C<< '>:raw' >>.

=item B<Multiple copyright lines in one credit element>

If you call C<_emit_credits> and place two C<< <credit-words> >> children
inside a single C<< <credit> >> element, most renderers (including MuseScore
and Finale) display only the B<last> C<< <credit-words> >> and silently
discard the rest.  This module uses one C<< <credit> >> per copyright line to
avoid this.

=item B<Missing defaults section>

The absolute coordinates in C<< <credit> >> elements (C<default-x>,
C<default-y>) are measured in MusicXML "tenths" from the bottom-left corner
of the page.  They are meaningless to a renderer unless a C<< <defaults> >>
block defines the page size and the tenths-per-mm scaling factor.  This module
always emits C<< <defaults> >> before any C<< <credit> >> elements.

=item B<Two separate rights elements in identification>

C<< <identification> >> accepts only one C<< <rights> >> child in practice;
a second one shadows the first.  This module joins multiple copyright lines
with a newline character inside a single C<< <rights> >> element.

=item B<Slur token treated as an articulation>

NWC encodes slurs as C<Slur> in the same token list as articulations.  Do not
add C<Slur> to C<%ARTICULATION_MAP>; the slur-annotation pre-pass handles it.
If C<Slur> were also emitted as an articulation element the output XML would be
invalid.

=item B<Score with no staves>

Calling C<generate> on a C<Score> object that has no staves will C<croak>
immediately.  Always check that the parser produced at least one staff before
calling the generator.

=back

=head1 ENCODING

=over 4

=item B<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 C<_xml_escape> before being written to the output.

=item B<Output (the generated XML string)>

The string returned by C<generate> is B<pure 7-bit ASCII>.  Every character
whose code point is above 127 is converted to a numeric XML character
reference (C<&#N;>), for example C<&#169;> for the copyright symbol.  The XML
declaration at the top of the document reads C<encoding="UTF-8">, which
remains correct because numeric character references are valid in any XML
encoding.

=item B<Emojis and full Unicode>

Emoji and supplementary-plane characters (code points above U+FFFF) are not
tested but will be escaped correctly by C<_xml_escape> as long as Perl has
decoded them to proper Unicode code points (i.e. C<utf8::decode> has been
applied or the string was read with a C<:utf8> layer).  Raw multi-byte UTF-8
bytes that have B<not> been decoded will be escaped byte-by-byte and will
produce incorrect numeric references.

=back

=cut
435
436# ---------------------------------------------------------------------------
437# new
438# ---------------------------------------------------------------------------
439
440 - 500
=head2 new

Create a new generator object.

=head3 Purpose

Factory constructor.  Creates a configured generator ready to call C<generate>
one or more times.  The same generator instance can be used to process
multiple Score objects; each C<generate> call is independent.

=head3 Arguments

All arguments are named (passed as a flat key/value list) and optional.

=over 4

=item C<indent>

The string used for one level of XML indentation.  Defaults to two spaces.
Pass C<"\t"> for tab indentation.  Only affects whitespace; the XML content
is identical regardless of the indent setting.

=item C<diagnostics>

A C<Music::NWC2MusicXML::Diagnostics> instance for routing warning messages.
When omitted, warnings are sent directly to C<carp>.

=back

=head3 Returns

A blessed C<Music::NWC2MusicXML::MusicXML> object.

=head3 Side Effects

None.

=head3 Usage Example

    # Default (two-space indent)
    my $gen = Music::NWC2MusicXML::MusicXML->new;

    # Tab indent
    my $gen = Music::NWC2MusicXML::MusicXML->new(indent => "\t");

=head3 API SPECIFICATION

=head4 Input

    indent      : SCALAR   (optional, default '  ')
    diagnostics : OBJECT   Music::NWC2MusicXML::Diagnostics  (optional)

=head4 Output

    Music::NWC2MusicXML::MusicXML object

=head3 MESSAGES

This method does not emit any diagnostic messages.

=cut
501
502sub new {
503
254
428355
        my ($class, %input) = @_;
504
254
458
        my $args = validate_strict(
505                schema => {
506                        diagnostics => { type => 'object', optional => 1 },
507                        indent      => { type => 'scalar', optional => 1, default => '  ' },
508                },
509                input => \%input,
510        );
511
254
10354
        croak $@ unless defined $args;
512
513        my $self = bless {
514                _diagnostics => $args->{diagnostics},
515                _indent      => $args->{indent},
516
254
273
        }, $class;
517
518
254
411
        return $self;
519}
520
521# ---------------------------------------------------------------------------
522# Public: generate
523# ---------------------------------------------------------------------------
524
525 - 632
=head2 generate

Convert a C<Music::NWC2MusicXML::Score> object to a MusicXML 4.0 document and
return the complete document as a string.

=head3 Purpose

Top-level entry point.  Orchestrates, in order:

=over 4

=item 1. XML declaration and DOCTYPE header.

=item 2. Page layout geometry (C<< <defaults> >>).

=item 3. Work title (C<< <work> >>).

=item 4. Identification metadata: composer, lyricist, rights (C<< <identification> >>).

=item 5. Visual credits: title, subtitle, and copyright lines (C<< <credit> >> elements).

=item 6. Part list: one C<< <score-part> >> per staff (C<< <part-list> >>).

=item 7. Musical content: one C<< <part> >> per staff, each containing numbered
measures with notes, rests, dynamics, tempo, articulations, slurs, ties,
and wedge hairpins.

=back

Each staff is processed by a two-stage pipeline:

=over 4

=item Stage 1 -- annotation pre-passes.

C<_annotate_events> detects slur arcs and tie pairs across the entire staff
before measure grouping.  C<_annotate_wedges> detects hairpin (crescendo /
diminuendo) arcs stored as per-note C<Opts:> flags.  C<_annotate_fermatas>
associates C<TempoVariance:Fermata> records with the sounding event that
follows each one.  All three passes store their results in a shared C<%ann>
hash keyed by stringified event reference.

=item Stage 2 -- measure emission.

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

=back

=head3 Arguments

=over 4

=item C<$score>

A C<Music::NWC2MusicXML::Score> object (required).  Must contain at least one
staff; otherwise the method croaks.

=back

=head3 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 (C<&#N;>).  The XML declaration at the top of the
string declares C<encoding="UTF-8">, which is correct.

The string ends with a single newline character.

=head3 Side Effects

May issue warnings via C<carp> for unsupported or unrecognised values
(unknown clef, unknown dynamic marking, unsupported articulation token,
approximate barline style).

=head3 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;

=head3 API SPECIFICATION

=head4 Input

    $score : Music::NWC2MusicXML::Score  (required, must have staff_count > 0)

=head4 Output

    SCALAR  -- complete MusicXML 4.0 document, pure 7-bit ASCII, newline-terminated

=head3 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            |

=cut
633
634sub generate {
635
210
671
        my ($self, $score) = @_;
636
637
210
481
        croak _fmt_msg('error_bad_score')
638                unless blessed($score) && $score->isa('Music::NWC2MusicXML::Score');
639
640
199
202
        croak _fmt_msg('error_no_staves')
641                unless $score->staff_count > 0;
642
643        # Calculate the divisions value from all note durations in the score.
644
194
238
        my $divisions = $self->_calculate_divisions($score);
645
646
194
79
        my @out;
647
648
194
134
        push @out, '<?xml version="1.0" encoding="UTF-8"?>';
649
194
161
        push @out, sprintf('<!DOCTYPE score-partwise PUBLIC "%s" "%s">',
650                $DOCTYPE_PUBLIC, $DOCTYPE_SYSTEM);
651
194
134
        push @out, sprintf('<score-partwise version="%s">', $MUSICXML_VERSION);
652
653
194
198
        my $layout = _compute_page_layout($score->page_setup // {});
654
655
194
173
        push @out, $self->_emit_work($score->metadata);
656
194
143
        push @out, $self->_emit_identification($score->metadata);
657
194
230
        push @out, $self->_emit_defaults($layout, $score->fonts);
658
194
170
        push @out, $self->_emit_credits($score->metadata, $layout);
659
194
159
        push @out, $self->_emit_part_list($score->staves);
660
194
155
        push @out, $self->_emit_parts($score->staves, $divisions);
661
662
194
20802
        push @out, '</score-partwise>';
663
664
194
113453
        return join("\n", @out) . "\n";
665}
666
667# ---------------------------------------------------------------------------
668# Private: page layout constants and helpers
669# ---------------------------------------------------------------------------
670
671# Standard MusicXML scaling: 40 tenths per staff space, 7.2175 mm per space.
672Readonly::Scalar my $MM_PER_SPACE  => 7.2175;
673Readonly::Scalar my $TENTHS_PER_SPACE => 40;
674Readonly::Scalar my $TENTHS_PER_MM => $TENTHS_PER_SPACE / $MM_PER_SPACE;
675
676# Default to A4 paper (210 x 297 mm); most common in international music publishing.
677Readonly::Scalar my $DEFAULT_PAGE_W_MM => 210.0;
678Readonly::Scalar my $DEFAULT_PAGE_H_MM => 297.0;
679Readonly::Scalar my $DEFAULT_MARGIN_CM => 1.27;   # standard NWC default
680
681# Derive page geometry in tenths from PgSetup/PgMargins fields (cm margins).
682# Returns a hashref: page_height, page_width, margin_l, margin_r, margin_t,
683# margin_b, center_x, right_x.
684sub _compute_page_layout {
685
197
1760
        my ($ps) = @_;
686
197
141
        $ps //= {};
687
688        # Margins: NWC stores them in cm (Left/Top/Right/Bottom from PgMargins).
689        # When a side is absent, fall back to Left, then the hardcoded default.
690
197
231
        my $left   = $ps->{Left}   // $DEFAULT_MARGIN_CM;
691
197
199
        my $right  = $ps->{Right}  // $left;
692
197
196
        my $top    = $ps->{Top}    // $left;
693
197
188
        my $bottom = $ps->{Bottom} // $left;
694
695
197
312
        my $margin_l = $left   * 10 * $TENTHS_PER_MM;
696
197
114
        my $margin_r = $right  * 10 * $TENTHS_PER_MM;
697
197
106
        my $margin_t = $top    * 10 * $TENTHS_PER_MM;
698
197
92
        my $margin_b = $bottom * 10 * $TENTHS_PER_MM;
699
700
197
90
        my $page_h = $DEFAULT_PAGE_H_MM * $TENTHS_PER_MM;
701
197
95
        my $page_w = $DEFAULT_PAGE_W_MM * $TENTHS_PER_MM;
702
703        return {
704
197
429
                page_height => $page_h,
705                page_width  => $page_w,
706                margin_l    => $margin_l,
707                margin_r    => $margin_r,
708                margin_t    => $margin_t,
709                margin_b    => $margin_b,
710                center_x    => $page_w / 2,
711                right_x     => $page_w - $margin_r,
712        };
713}
714
715sub _emit_defaults {
716
194
133
        my ($self, $layout, $fonts) = @_;
717
194
106
        $fonts //= [];
718
194
77
        my @out;
719
194
91
        my $i = $self->{_indent};
720
721
194
686
        my $ph = sprintf '%.2f', $layout->{page_height};
722
194
188
        my $pw = sprintf '%.2f', $layout->{page_width};
723
194
179
        my $ml = sprintf '%.2f', $layout->{margin_l};
724
194
150
        my $mr = sprintf '%.2f', $layout->{margin_r};
725
194
148
        my $mt = sprintf '%.2f', $layout->{margin_t};
726
194
133
        my $mb = sprintf '%.2f', $layout->{margin_b};
727
728
194
82
        push @out, '<defaults>';
729
194
114
        push @out, "${i}<scaling>";
730
194
239
        push @out, "${i}${i}<millimeters>$MM_PER_SPACE</millimeters>";
731
194
153
        push @out, "${i}${i}<tenths>$TENTHS_PER_SPACE</tenths>";
732
194
114
        push @out, "${i}</scaling>";
733
194
91
        push @out, "${i}<page-layout>";
734
194
110
        push @out, "${i}${i}<page-height>$ph</page-height>";
735
194
117
        push @out, "${i}${i}<page-width>$pw</page-width>";
736
194
93
        push @out, "${i}${i}<page-margins type=\"both\">";
737
194
103
        push @out, "${i}${i}${i}<left-margin>$ml</left-margin>";
738
194
128
        push @out, "${i}${i}${i}<right-margin>$mr</right-margin>";
739
194
101
        push @out, "${i}${i}${i}<top-margin>$mt</top-margin>";
740
194
108
        push @out, "${i}${i}${i}<bottom-margin>$mb</bottom-margin>";
741
194
93
        push @out, "${i}${i}</page-margins>";
742
194
108
        push @out, "${i}</page-layout>";
743
744        # Font declarations from NWC Font records (word-font then lyric-font per schema order)
745
194
67
        my ($word_font, $lyric_font);
746
194
126
        for my $font (@$fonts) {
747
349
395
                next unless length($font->{typeface} // '');
748
349
256
                my $style = $font->{style} // '';
749
349
394
                if ($style eq 'StaffLyric' && !defined $lyric_font) {
750
30
17
                        $lyric_font = $font;
751                } elsif ($style =~ /\AStaff/i && !defined $word_font) {
752
29
25
                        $word_font = $font;
753                }
754        }
755
194
188
        for my $pair ([$word_font, 'word-font'], [$lyric_font, 'lyric-font']) {
756
388
181
                my ($font, $elem) = @$pair;
757
388
239
                next unless defined $font;
758
59
93
                my $tf  = _xml_escape($font->{typeface} // '');
759
59
104
                my $sz  = $font->{size} ? " font-size=\"$font->{size}\"" : '';
760
59
65
                my $wt  = $font->{bold}   ? ' font-weight="bold"'   : '';
761
59
55
                my $fst = $font->{italic} ? ' font-style="italic"' : '';
762
59
90
                push @out, "${i}<$elem font-family=\"$tf\"${sz}${wt}${fst}/>";
763        }
764
765
194
144
        push @out, '</defaults>';
766
194
309
        return @out;
767}
768
769# ---------------------------------------------------------------------------
770# Private: top-level sections
771# ---------------------------------------------------------------------------
772
773sub _emit_work {
774
194
119
        my ($self, $meta) = @_;
775
194
87
        my @out;
776
194
143
        my $i = $self->{_indent};
777
194
105
        push @out, '<work>';
778        push @out, "${i}<work-title>" . _xml_escape($meta->{Title} // '') . '</work-title>'
779
194
321
                if $meta->{Title};
780
194
110
        push @out, '</work>';
781
194
165
        return @out;
782}
783
784sub _emit_identification {
785
194
99
        my ($self, $meta) = @_;
786
194
76
        my @out;
787
194
112
        my $i = $self->{_indent};
788
194
104
        push @out, '<identification>';
789        push @out, "${i}<creator type=\"composer\">"
790                . _xml_escape($meta->{Author} // '') . '</creator>'
791
194
219
                if $meta->{Author};
792        push @out, "${i}<creator type=\"lyricist\">"
793                . _xml_escape($meta->{Lyricist} // '') . '</creator>'
794
194
155
                if $meta->{Lyricist};
795        # Combine all copyright lines into one <rights> element; multiple <rights>
796        # elements cause renderers to discard all but the last.
797
194
105
        my @cr_lines;
798
194
317
        if (defined $meta->{Copyright1} || defined $meta->{Copyright2}) {
799                push @cr_lines, $meta->{Copyright1}
800
47
148
                        if defined $meta->{Copyright1} && length $meta->{Copyright1};
801                push @cr_lines, $meta->{Copyright2}
802
47
141
                        if defined $meta->{Copyright2} && length $meta->{Copyright2};
803        } elsif (defined $meta->{Copyright} && length $meta->{Copyright}) {
804
0
0
                push @cr_lines, $meta->{Copyright};
805        }
806
194
213
        push @out, "${i}<rights>" . _xml_escape(join "\n", @cr_lines) . '</rights>'
807                if @cr_lines;
808
194
160
        push @out, "${i}<encoding>";
809
194
172
        push @out, "${i}${i}<software>Music::NWC2MusicXML $VERSION</software>";
810
194
115
        push @out, "${i}</encoding>";
811
194
190
        if (defined $meta->{Comments} && length($meta->{Comments} // '')) {
812
0
0
                push @out, "${i}<miscellaneous>";
813                push @out, "${i}${i}<miscellaneous-field name=\"comments\">"
814
0
0
                        . _xml_escape($meta->{Comments}) . '</miscellaneous-field>';
815
0
0
                push @out, "${i}</miscellaneous>";
816        }
817
194
110
        push @out, '</identification>';
818
194
170
        return @out;
819}
820
821sub _emit_credits {
822
194
115
        my ($self, $meta, $layout) = @_;
823
194
112
        $layout //= _compute_page_layout({});
824
194
74
        my @out;
825
194
118
        my $i = $self->{_indent};
826
827
194
194
        my $cx  = sprintf '%.2f', $layout->{center_x};
828
194
205
        my $ty  = sprintf '%.2f', $layout->{page_height} - $layout->{margin_t};
829
194
201
        my $sy  = sprintf '%.2f', $layout->{page_height} - $layout->{margin_t} - 60;
830
194
106
        my $bot = $layout->{margin_b};
831
832        # Title credit on page 1 (large, centred near top)
833
194
226
        if (defined $meta->{Title} && length $meta->{Title}) {
834
83
62
                push @out, '<credit page="1">';
835
83
80
                push @out, "${i}<credit-type>title</credit-type>";
836                push @out, "${i}<credit-words"
837                        . " default-x=\"$cx\" default-y=\"$ty\""
838                        . ' justify="center" valign="top"'
839                        . ' font-size="24"'
840
83
123
                        . '>' . _xml_escape($meta->{Title}) . '</credit-words>';
841
83
56
                push @out, '</credit>';
842        }
843
844        # Subtitle credit on page 1 (centred, just below title)
845
194
202
        if (defined $meta->{Author} && length $meta->{Author}) {
846
42
28
                push @out, '<credit page="1">';
847
42
38
                push @out, "${i}<credit-type>subtitle</credit-type>";
848                push @out, "${i}<credit-words"
849                        . " default-x=\"$cx\" default-y=\"$sy\""
850                        . ' justify="center" valign="top"'
851                        . ' font-size="14"'
852
42
58
                        . '>' . _xml_escape($meta->{Author}) . '</credit-words>';
853
42
40
                push @out, '</credit>';
854        }
855
856        # Copyright lines: each gets its OWN <credit> element (no page attribute ->
857        # appears on every page). Multiple <credit-words> in one <credit> cause
858        # renderers to show only the last element.
859
194
62
        my @cr_lines;
860
194
325
        if (defined $meta->{Copyright1} || defined $meta->{Copyright2}) {
861                push @cr_lines, $meta->{Copyright1}
862
47
130
                        if defined $meta->{Copyright1} && length $meta->{Copyright1};
863                push @cr_lines, $meta->{Copyright2}
864
47
100
                        if defined $meta->{Copyright2} && length $meta->{Copyright2};
865        } elsif (defined $meta->{Copyright} && length $meta->{Copyright}) {
866
0
0
                push @cr_lines, $meta->{Copyright};
867        }
868
869        # Stack lines from bottom margin upward: last line at margin, each prior
870        # line 14 tenths higher.
871
194
98
        my $line_step = 14;
872
194
90
        my $n         = scalar @cr_lines;
873
194
170
        for my $idx (0 .. $#cr_lines) {
874
88
107
                my $y = sprintf '%.2f', $bot + $line_step * ($n - 1 - $idx);
875
88
44
                push @out, '<credit>';
876
88
57
                push @out, "${i}<credit-type>rights</credit-type>";
877
88
83
                push @out, "${i}<credit-words"
878                        . " default-x=\"$cx\" default-y=\"$y\""
879                        . ' justify="center" valign="bottom"'
880                        . ' font-size="10"'
881                        . '>' . _xml_escape($cr_lines[$idx]) . '</credit-words>';
882
88
71
                push @out, '</credit>';
883        }
884
885
194
199
        return @out;
886}
887
888sub _emit_part_list {
889
194
110
        my ($self, $staves) = @_;
890
194
105
        my @out;
891
194
114
        my $i = $self->{_indent};
892
893
194
173
        my @names  = _resolve_part_names($staves);
894
194
179
        my @groups = _compute_groups($staves);
895
896
194
92
        push @out, '<part-list>';
897
194
90
        my $part_id = 1;
898
194
120
        for my $staff (@$staves) {
899
396
172
                my $idx = $part_id - 1;
900
901                # Groups starting before this score-part (outer before inner)
902
396
29
232
210
70
200
                for my $g (sort { $a->{num} <=> $b->{num} } grep { $_->{start} == $idx } @groups) {
903
58
50
                        push @out, "${i}<part-group type=\"start\" number=\"$g->{num}\">";
904
58
71
                        push @out, "${i}${i}<group-symbol>$g->{symbol}</group-symbol>";
905
58
42
                        push @out, "${i}${i}<group-barline>$g->{bars}</group-barline>";
906
58
45
                        push @out, "${i}</part-group>";
907                }
908
909
396
172
                my $id   = "P$part_id";
910
396
195
                my $name = _xml_escape($names[$idx]);
911
396
223
                push @out, "${i}<score-part id=\"$id\">";
912
396
343
                my $print_attr = ($staff->{_visible} // 1) ? '' : ' print-object="no"';
913
396
229
                push @out, "${i}${i}<part-name${print_attr}>$name</part-name>";
914
396
220
                my $instr = $staff->instrument;
915
396
345
                if ($instr && $instr->{name}) {
916
125
76
                        push @out, "${i}${i}<score-instrument id=\"${id}-I1\">";
917                        push @out, "${i}${i}${i}<instrument-name>"
918
125
115
                                . _xml_escape($instr->{name}) . '</instrument-name>';
919
125
74
                        push @out, "${i}${i}</score-instrument>";
920
125
93
                        if (defined $instr->{patch}) {
921
124
100
                                my $chan = $staff->{_channel} // 1;
922
124
72
                                push @out, "${i}${i}<midi-instrument id=\"${id}-I1\">";
923
124
69
                                push @out, "${i}${i}${i}<midi-channel>$chan</midi-channel>";
924                                push @out, "${i}${i}${i}<midi-program>"
925
124
156
                                        . ($instr->{patch} + 1) . '</midi-program>';
926
124
73
                                push @out, "${i}${i}</midi-instrument>";
927                        }
928                }
929
396
185
                push @out, "${i}</score-part>";
930
931                # Groups stopping after this score-part (inner before outer)
932
396
29
232
182
19
115
                for my $g (sort { $b->{num} <=> $a->{num} } grep { $_->{end} == $idx } @groups) {
933
58
58
                        push @out, "${i}<part-group type=\"stop\" number=\"$g->{num}\"/>";
934                }
935
936
396
166
                $part_id++;
937        }
938
194
69
        push @out, '</part-list>';
939
194
375
        return @out;
940}
941
942# Compute bracket/brace grouping structure from WithNextStaff flags.
943# Returns a list of group hashrefs: {num, start, end, symbol, bars}.
944# Bracket groups are numbered before Brace groups so outer comes first.
945sub _compute_groups {
946
194
99
        my ($staves) = @_;
947
194
87
        my @all_groups;
948
194
95
        my $group_num = 1;
949
950
194
118
        for my $sym (qw(Bracket Brace)) {
951
388
105
                my $i = 0;
952
388
227
                while ($i < @$staves) {
953
618
409
                        my $wnxs  = $staves->[$i]{_with_next_staff} // '';
954
618
174
348
136
                        my %flags = map { $_ => 1 } split /,/, $wnxs;
955
956
618
265
                        if ($flags{$sym}) {
957
58
26
                                my $start        = $i;
958
58
53
                                my $connect_bars = $flags{ConnectBars} ? 'yes' : 'no';
959                                # Extend the group while consecutive staves carry the same flag.
960
58
65
                                while ($i < @$staves - 1) {
961
174
113
                                        my $cur = $staves->[$i]{_with_next_staff} // '';
962
174
522
99
206
                                        my %cf  = map { $_ => 1 } split /,/, $cur;
963
174
105
                                        last unless $cf{$sym};
964
174
87
                                        $i++;
965                                }
966
58
148
                                push @all_groups, {
967                                        num    => $group_num++,
968                                        start  => $start,
969                                        end    => $i,
970                                        symbol => lc($sym),
971                                        bars   => $connect_bars,
972                                } if $i > $start;
973
58
57
                                $i++;
974                        } else {
975
560
280
                                $i++;
976                        }
977                }
978        }
979
980
194
113
        return @all_groups;
981}
982
983# ---------------------------------------------------------------------------
984# Private: part-name resolution
985# ---------------------------------------------------------------------------
986
987# Resolve a display name for every staff, applying a three-level priority:
988#   1. The NWC staff name, if it is not a generic default.
989#   2. The MIDI instrument name, if distinct across all staves using it.
990#   3. "Staff-N" (1-based) as the unconditional last resort.
991#
992# After candidates are chosen, any name that appears more than once is
993# replaced with "Staff-N" to guarantee unique part names in the output.
994sub _resolve_part_names {
995
198
116
        my ($staves) = @_;
996
997        # Build candidates
998
198
71
        my @candidates;
999
198
106
        my $n = 1;
1000
198
109
        for my $staff (@$staves) {
1001
403
264
                push @candidates, _candidate_part_name($staff, $n++);
1002        }
1003
1004        # Count how many staves share each candidate name
1005
198
75
        my %freq;
1006
198
277
        $freq{$_}++ for @candidates;
1007
1008        # Replace any duplicated name with a positional "Staff-N" fallback
1009
198
70
        my @resolved;
1010
198
83
        my $pos = 1;
1011
198
118
        for my $cand (@candidates) {
1012
403
724
                push @resolved, $freq{$cand} > 1 ? "Staff-$pos" : $cand;
1013
403
149
                $pos++;
1014        }
1015
1016
198
207
        return @resolved;
1017}
1018
1019# Return a candidate name for one staff before deduplication.
1020sub _candidate_part_name {
1021
403
185
        my ($staff, $part_num) = @_;
1022
403
242
        my $name = $staff->name // '';
1023
1024        # Accept the NWC name unless it matches NWC's own generic defaults
1025        # ("Staff", "Staff-0" .. "Staff-99").
1026
403
621
        return $name if length $name && $name !~ /^Staff(?:-\d+)?$/i;
1027
1028        # Try the instrument name recorded in the MIDI settings.
1029
126
99
        my $instr = $staff->instrument // {};
1030        return $instr->{name}
1031
126
245
                if defined $instr->{name} && length $instr->{name};
1032
1033        # Positional fallback: 1-based "Staff-N".
1034
8
6
        return "Staff-$part_num";
1035}
1036
1037sub _emit_parts {
1038
194
124
        my ($self, $staves, $divisions) = @_;
1039
194
72
        my @out;
1040
194
78
        my $part_id = 1;
1041
194
117
        for my $staff (@$staves) {
1042
396
501
                push @out, $self->_emit_part("P$part_id", $staff, $divisions);
1043
396
16922
                $part_id++;
1044        }
1045
194
164234
        return @out;
1046}
1047
1048sub _emit_part {
1049
396
287
        my ($self, $id, $staff, $divisions) = @_;
1050
396
172
        my @out;
1051
1052
396
284
        push @out, "<part id=\"$id\">";
1053
1054
396
166
        my $measure_no = 1;
1055
396
215
        my @pending    = ();
1056
396
163
        my $first      = 1;
1057
396
193
        my $prev_bar   = 'normal';
1058
1059        # $clef_start / $key_start = state at the beginning of the current measure.
1060        # $clef_now   / $key_now   = state updated live as we scan events.
1061        # They diverge when Clef/Key events appear mid-measure; at each Bar we
1062        # commit the live values as the start-of-next-measure state.
1063
396
498
        my $clef_start  = $staff->initial_clef // 'Treble';
1064
396
412
        my $key_start   = ($staff->initial_key  // {})->{fifths} // 0;
1065
396
180
        my $clef_now    = $clef_start;
1066
396
151
        my $key_now     = $key_start;
1067
1068        # Expose per-staff DynVel map for _emit_dynamic to use during this part.
1069
396
285
        $self->{_dyn_vel} = $staff->{_dyn_vel};
1070
1071        # Pre-annotate all events with slur/tie and wedge metadata in one pass
1072        # so that arcs crossing bar lines are handled correctly.
1073
396
357
        my $ann = $self->_annotate_events($staff->events);
1074
396
416
        $self->_annotate_wedges($staff->events, $ann);
1075
396
330
        $self->_annotate_lyrics($staff->events, $ann);
1076
396
280
        $self->_annotate_fermatas($staff->events, $ann);
1077
1078
396
396
324
268
        for my $event (@{ $staff->events }) {
1079
203081
94171
                my $type = $event->type;
1080
1081
203081
77151
                if ($type eq 'Bar') {
1082
45253
15705
                        my $bar_style = $event->data->{style} // 'normal';
1083
45253
19048
                        push @out, $self->_emit_measure(
1084                                $measure_no++, \@pending, $staff, $divisions,
1085                                $first, $clef_start, $key_start, $prev_bar, $bar_style, $ann
1086                        );
1087
45253
29491
                        @pending    = ();
1088
45253
11515
                        $first      = 0;
1089
45253
13057
                        $prev_bar   = $bar_style;
1090
45253
11874
                        $clef_start = $clef_now;   # carry updated state to next measure
1091
45253
17729
                        $key_start  = $key_now;
1092
1093                } elsif ($type eq 'Clef') {
1094
206
212
                        $clef_now = $event->data->{nwc_clef} // $clef_now;
1095
206
242
                        push @pending, $event;     # included so _emit_measure can emit <attributes>
1096
1097                } elsif ($type eq 'Key') {
1098
581
384
                        $key_now = ($event->data // {})->{fifths} // 0;
1099
581
379
                        push @pending, $event;
1100
1101                } else {
1102
157041
57087
                        push @pending, $event;
1103                }
1104        }
1105
1106
396
356
        if (@pending || $measure_no == 1) {
1107                push @out, $self->_emit_measure(
1108                        $measure_no, \@pending, $staff, $divisions,
1109                        $first, $clef_start, $key_start, $prev_bar,
1110
157
389
                        $staff->{_ending_bar} // 'normal', $ann
1111                );
1112        }
1113
1114
396
315
        delete $self->{_dyn_vel};
1115
396
189
        push @out, "</part>";
1116
396
167789
        return @out;
1117}
1118
1119sub _emit_measure {
1120
45410
18995
        my ($self, $number, $events, $staff, $divisions, $is_first,
1121            $clef, $key_fifths, $prev_bar, $bar_style, $ann) = @_;
1122
45410
16047
        my $curr_clef  = $clef       // 'Treble';
1123
45410
14530
        my $curr_key   = $key_fifths // 0;
1124
45410
14569
        $prev_bar  //= 'normal';
1125
45410
13026
        $bar_style //= 'normal';
1126
45410
12824
        $ann       //= {};
1127
1128
45410
10629
        my @out;
1129
45410
13378
        my $i   = $self->{_indent};
1130
45410
11980
        my $pad = $i;
1131
45410
10827
        my $bar_accidentals = {};   # pitch_key -> alter; reset each measure
1132
1133
45410
17749
        push @out, "${pad}<measure number=\"$number\">";
1134
1135
45410
24156
        if ($prev_bar eq 'MasterRepeatOpen' || $prev_bar eq 'LocalRepeatOpen') {
1136
464
560
                push @out, "${pad}${i}<barline location=\"left\">";
1137
464
355
                push @out, "${pad}${i}${i}<bar-style>heavy-light</bar-style>";
1138
464
302
                push @out, "${pad}${i}${i}<repeat direction=\"forward\"/>";
1139
464
346
                push @out, "${pad}${i}</barline>";
1140        }
1141
1142
45410
14021
        if ($is_first) {
1143
396
615
                push @out, $self->_emit_attributes($staff, $divisions, $pad . $i);
1144                # Emit MIDI playback volume/pan at the start of the first measure.
1145
396
395
                my ($vol, $pan) = ($staff->{_volume}, $staff->{_stereo_pan});
1146
396
441
                if (defined $vol || defined $pan) {
1147
116
139
                        my @sa;
1148
116
609
                        push @sa, sprintf('dynamics="%d"', int($vol * 100 / 127 + 0.5)) if defined $vol;
1149
116
285
                        push @sa, sprintf('pan="%d"',      int(($pan - 64) * 90 / 63 + 0.5)) if defined $pan;
1150
116
387
                        push @out, "${pad}${i}<sound " . join(' ', @sa) . '/>' if @sa;
1151                }
1152        }
1153
1154
45410
14076
        for my $event (@$events) {
1155
157828
56805
                my $type   = $event->type;
1156
157828
97198
                my $ev_ann = $ann->{"$event"} // {};
1157
1158
157828
144301
                if ($type eq 'TimeSig') {
1159
2320
1124
                        push @out, $self->_emit_time_change($event->data // {}, $pad . $i);
1160
1161                } elsif ($type eq 'Clef') {
1162
206
150
                        $curr_clef = $event->data->{nwc_clef} // $curr_clef;
1163
206
383
                        push @out, $self->_emit_clef_change($curr_clef, $pad . $i);
1164
1165                } elsif ($type eq 'Key') {
1166
581
419
                        my $kd = $event->data // {};
1167
581
491
                        $curr_key = $kd->{fifths} // 0;
1168
581
1168
                        push @out, $self->_emit_key_change($kd, $pad . $i);
1169
1170                } elsif ($type eq 'Tempo') {
1171
155
137
                        my $d = $event->data // {};
1172                        push @out, $self->_emit_tempo(
1173
155
415
                                $d->{bpm}, $d->{base}, $d->{text} // '', $pad . $i);
1174
1175                } elsif ($type eq 'Dynamic') {
1176
2902
1255
                        my $d = $event->data // {};
1177                        push @out, $self->_emit_dynamic(
1178
2902
3224
                                $d->{marking}, $d->{placement}, $pad . $i);
1179
1180                } elsif ($type eq 'DynVariance') {
1181
3
1
                        my $d = $event->data // {};
1182                        push @out, $self->_emit_dyn_variance(
1183
3
5
                                $d->{style}, $d->{placement}, $pad . $i);
1184
1185                } elsif ($type eq 'TempoVariance') {
1186
129
110
                        my $d = $event->data // {};
1187
129
249
                        next if ($d->{style} // '') eq 'Fermata';   # handled as <fermata/> on following note
1188                        push @out, $self->_emit_tempo_variance(
1189
124
278
                                $d->{style}, $d->{placement}, $pad . $i);
1190
1191                } elsif ($type eq 'Text') {
1192
0
0
                        my $d = $event->data // {};
1193                        push @out, $self->_emit_text_direction(
1194
0
0
                                $d->{text}, $d->{placement}, $pad . $i);
1195
1196                } elsif ($type eq 'FlowControl') {
1197
0
0
                        my $d = $event->data // {};
1198
0
0
                        push @out, $self->_emit_flow_control($d->{directive}, $pad . $i);
1199
1200                } elsif ($type eq 'Instrument') {
1201
0
0
                        my $d = $event->data // {};
1202                        push @out, $self->_emit_instrument_change(
1203
0
0
                                $d->{name}, $d->{patch}, $pad . $i);
1204
1205                } elsif ($type eq 'Note') {
1206                        push @out, $self->_emit_wedge($ev_ann->{wedge_start}, undef, $pad . $i)
1207
103099
38292
                                if $ev_ann->{wedge_start};
1208
103099
44178
                        push @out, $self->_emit_note_event(
1209                                $event, $curr_clef, $curr_key, $divisions, $pad . $i, 0, $ev_ann, $bar_accidentals);
1210                        push @out, $self->_emit_wedge('crescOff', undef, $pad . $i)
1211
103099
56399
                                if $ev_ann->{wedge_stop_after};
1212                } elsif ($type eq 'Rest') {
1213                        push @out, $self->_emit_wedge($ev_ann->{wedge_start}, undef, $pad . $i)
1214
23463
7881
                                if $ev_ann->{wedge_start};
1215
23463
10681
                        push @out, $self->_emit_rest_event($event, $divisions, $pad . $i, $ev_ann);
1216                        push @out, $self->_emit_wedge('crescOff', undef, $pad . $i)
1217
23463
12181
                                if $ev_ann->{wedge_stop_after};
1218                } elsif ($type eq 'Chord') {
1219                        push @out, $self->_emit_wedge($ev_ann->{wedge_start}, undef, $pad . $i)
1220
24911
10116
                                if $ev_ann->{wedge_start};
1221
24911
11597
                        push @out, $self->_emit_chord_event(
1222                                $event, $curr_clef, $curr_key, $divisions, $pad . $i, $ev_ann, $bar_accidentals);
1223                        push @out, $self->_emit_wedge('crescOff', undef, $pad . $i)
1224
24911
17463
                                if $ev_ann->{wedge_stop_after};
1225                }
1226        }
1227
1228
45410
30841
        if ($bar_style eq 'MasterRepeatClose') {
1229
464
494
                push @out, "${pad}${i}<barline location=\"right\">";
1230
464
495
                push @out, "${pad}${i}${i}<bar-style>light-heavy</bar-style>";
1231
464
416
                push @out, "${pad}${i}${i}<repeat direction=\"backward\"/>";
1232
464
363
                push @out, "${pad}${i}</barline>";
1233        } elsif ($bar_style eq 'Double') {
1234
928
788
                push @out, "${pad}${i}<barline location=\"right\">";
1235
928
694
                push @out, "${pad}${i}${i}<bar-style>light-light</bar-style>";
1236
928
771
                push @out, "${pad}${i}</barline>";
1237        } elsif ($bar_style eq 'SectionClose' || $bar_style eq 'LocalRepeatClose') {
1238
118
161
                push @out, "${pad}${i}<barline location=\"right\">";
1239
118
147
                push @out, "${pad}${i}${i}<bar-style>light-heavy</bar-style>";
1240
118
144
                push @out, "${pad}${i}</barline>";
1241        }
1242
1243
45410
14959
        push @out, "${pad}</measure>";
1244
45410
106827
        return @out;
1245}
1246
1247# ---------------------------------------------------------------------------
1248# Private: slur / tie annotation pass
1249# ---------------------------------------------------------------------------
1250
1251# Walk all events in a staff once and build an annotation hashref keyed by
1252# stringified event reference.  Each value is a hashref with:
1253#   slur_start      => N   this note opens slur arc N
1254#   slur_stop       => N   this note closes slur arc N (placed on the landing note, not the last Slur-bearing note)
1255#   tie_stop_keys   => { pos_key => 1, ... }  tie stops arriving at this note
1256#   tie_start_keys  => { pos_key => 1, ... }  tie starts leaving from this note
1257#
1258# "pos_key" is the raw position string with any ^ suffix stripped, used as an
1259# opaque key to match the tied-to note.  Ties that cross measure boundaries are
1260# handled correctly because we walk the entire event list before grouping.
1261
1262sub _annotate_events {
1263
399
239
        my ($self, $events) = @_;
1264
399
161
        my %ann;
1265
1266
399
161
        my $in_slur       = 0;
1267
399
154
        my $slur_num      = 0;
1268
399
206
        my $last_slur_ev  = undef;
1269
399
162
        my $last_slur_num = 0;
1270
399
155
        my %pending_tie;   # pos_key => 1 for notes awaiting a tie-stop
1271
1272
399
254
        for my $ev (@$events) {
1273
203087
97609
                my $type = $ev->type;
1274
203087
108402
                next unless $type eq 'Note' || $type eq 'Chord' || $type eq 'Rest';
1275
1276
151479
46246
                my $key = "$ev";   # stringified reference, unique per object
1277
1278                # Tie tracking (not applicable to rests)
1279
151479
48585
                unless ($type eq 'Rest') {
1280                        my @pos_strs = $type eq 'Chord'
1281
24911
7602
                                ? @{$ev->data->{nwc_positions} // []}
1282
128016
46932
                                : ($ev->data->{nwc_pos} // '0');
1283
1284
128016
35720
                        for my $ps (@pos_strs) {
1285
162468
46641
                                (my $pk = $ps) =~ s/\^\z//;   # strip tie marker to get the key
1286
1287
162468
50764
                                if (delete $pending_tie{$pk}) {
1288
5424
3858
                                        $ann{$key}{tie_stop_keys}{$pk} = 1;
1289                                }
1290
162468
64680
                                if ($ps =~ /\^\z/) {
1291
5482
4078
                                        $ann{$key}{tie_start_keys}{$pk} = 1;
1292
5482
2097
                                        $pending_tie{$pk} = 1;
1293                                }
1294                        }
1295                }
1296
1297                # Slur tracking (not applicable to rests -- rests are inside slur spans
1298                # but don't carry the arc endpoint markers)
1299
151479
52400
                next if $type eq 'Rest';
1300
1301
128016
37638
128016
46573
14212
40449
                my $has_slur = any { $_ eq 'Slur' } @{$ev->data->{articulations} // []};
1302
1303
128016
49859
                if ($has_slur) {
1304
24609
8346
                        unless ($in_slur) {
1305
6098
1630
                                $slur_num = ($slur_num % $MAX_SLUR_NUMBER) + 1;
1306
6098
4530
                                $ann{$key}{slur_start} = $slur_num;
1307
6098
1563
                                $in_slur = 1;
1308                        }
1309
24609
6388
                        $last_slur_ev  = $key;   # kept only for the end-of-staff fallback below
1310
24609
10650
                        $last_slur_num = $slur_num;
1311                } else {
1312
103407
47066
                        if ($in_slur) {
1313                                # Stop goes on the landing note (first note WITHOUT Slur), not the
1314                                # last note that carries Slur.  Placing it on the Slur-bearing note
1315                                # produces a zero-length arc when only one note has the token.
1316
6094
4776
                                $ann{$key}{slur_stop} = $last_slur_num;
1317
6094
1642
                                $in_slur      = 0;
1318
6094
2584
                                $last_slur_ev = undef;
1319                        }
1320                }
1321        }
1322
1323        # Close any slur still open at the end of the staff
1324
399
333
        $ann{$last_slur_ev}{slur_stop} = $last_slur_num if $in_slur && defined $last_slur_ev;
1325
1326
399
308
        return \%ann;
1327}
1328
1329# Associate each Lyric event with the immediately preceding Note or Chord event.
1330# Lyric events in NWCTXT always follow the note they annotate.
1331sub _annotate_lyrics {
1332
396
228
        my ($self, $events, $ann) = @_;
1333
396
217
        $ann //= {};
1334
1335
396
166
        my $last_note_key;
1336
396
236
        for my $ev (@$events) {
1337
203081
66948
                my $type = $ev->type;
1338
203081
99556
                if ($type eq 'Note' || $type eq 'Chord') {
1339
128010
41447
                        $last_note_key = "$ev";
1340                } elsif ($type eq 'Lyric' && defined $last_note_key) {
1341
0
0
                        my $d = $ev->data // {};
1342
0
0
                        push @{ $ann->{$last_note_key}{lyrics} }, {
1343                                text     => $d->{text}     // '',
1344                                verse    => $d->{verse}    // 1,
1345
0
0
                                syllabic => $d->{syllabic} // 'single',
1346                        };
1347                }
1348        }
1349
396
203
        return $ann;
1350}
1351
1352# Annotate wedge (hairpin) start/stop transitions into an existing %$ann hash.
1353# NWC stores hairpins as Opts:Crescendo / Opts:Diminuendo on each note/rest/chord
1354# under the arc, NOT as standalone DynVariance records.
1355# Sets wedge_start => 'Crescendo'|'Diminuendo' on the first event of each arc,
1356# and wedge_stop_after => 1 on the last event of each arc.
1357sub _annotate_wedges {
1358
399
248
        my ($self, $events, $ann) = @_;
1359
399
261
        $ann //= {};
1360
1361
399
149
        my $wedge_now      = undef;   # 'Crescendo' | 'Diminuendo' | undef
1362
399
152
        my $prev_wedge_key = undef;   # stringified ref of last event under the arc
1363
1364
399
252
        for my $ev (@$events) {
1365
203088
78963
                my $type = $ev->type;
1366
203088
104828
                next unless $type eq 'Note' || $type eq 'Rest' || $type eq 'Chord';
1367
1368
151480
44360
                my $key     = "$ev";
1369
151480
45610
                my $opts    = $ev->data->{opts} // {};
1370                my $ev_wedge = $opts->{Crescendo}  ? 'Crescendo'
1371
151480
72301
                             : $opts->{Diminuendo} ? 'Diminuendo'
1372                             : undef;
1373
1374
151480
47993
                if (defined $wedge_now && (!defined $ev_wedge || $ev_wedge ne $wedge_now)) {
1375
1135
1097
                        $ann->{$prev_wedge_key}{wedge_stop_after} = 1 if defined $prev_wedge_key;
1376
1135
370
                        $wedge_now = undef;
1377                }
1378
151480
69540
                if (!defined $wedge_now && defined $ev_wedge) {
1379
1139
1065
                        $ann->{$key}{wedge_start} = $ev_wedge;
1380
1139
333
                        $wedge_now = $ev_wedge;
1381                }
1382
1383
151480
58452
                $prev_wedge_key = defined $ev_wedge ? $key : undef;
1384        }
1385
1386        # Close any arc still open at end of staff
1387
399
286
        $ann->{$prev_wedge_key}{wedge_stop_after} = 1
1388                if defined $wedge_now && defined $prev_wedge_key;
1389
1390
399
238
        return $ann;
1391}
1392
1393# Associate each TempoVariance:Fermata with the immediately following Note,
1394# Rest, or Chord.  NWC places the TempoVariance record before the note it
1395# decorates; the fermata is a <notations><fermata/> element on that note, not
1396# a text direction.
1397sub _annotate_fermatas {
1398
406
289
        my ($self, $events, $ann) = @_;
1399
406
232
        $ann //= {};
1400
1401
406
208
        my $pending_fermata = 0;
1402
406
256
        for my $ev (@$events) {
1403
203104
64025
                my $type = $ev->type;
1404
203104
111960
                if ($type eq 'Note' || $type eq 'Rest' || $type eq 'Chord') {
1405
151486
50597
                        if ($pending_fermata) {
1406
12
12
                                $ann->{"$ev"}{fermata} = 1;
1407
12
8
                                $pending_fermata = 0;
1408                        }
1409                } elsif ($type eq 'TempoVariance'
1410                      && ($ev->data->{style} // '') eq 'Fermata') {
1411
14
10
                        $pending_fermata = 1;
1412                }
1413        }
1414
406
205
        return $ann;
1415}
1416
1417# ---------------------------------------------------------------------------
1418# Private: note / rest / chord XML emission
1419# ---------------------------------------------------------------------------
1420
1421sub _emit_note_event {
1422
162462
62363
        my ($self, $event, $clef, $key_fifths, $divisions, $pad, $is_chord_member, $ev_ann, $bar_accidentals) = @_;
1423
162462
50589
        $ev_ann          //= {};
1424
162462
49793
        $bar_accidentals //= {};
1425
162462
54469
        my $d   = $event->data;
1426
162462
42550
        my @out;
1427
162462
44923
        my $i   = $self->{_indent};
1428
1429
162462
92440
        my $pitch = $self->_pos_to_pitch($d->{nwc_pos} // '0', $clef, $key_fifths);
1430
1431        # Accidentals carry through the bar: once a pitch class is altered in a measure,
1432        # all subsequent notes at the same step+octave inherit that alteration.
1433
162462
55901
        my $pitch_key = $pitch->{step} . $pitch->{octave};
1434
162462
65987
        if (defined $pitch->{accidental}) {
1435
10819
4846
                $bar_accidentals->{$pitch_key} = $pitch->{alter};
1436        } elsif (exists $bar_accidentals->{$pitch_key}) {
1437
5570
1692
                $pitch->{alter} = $bar_accidentals->{$pitch_key};
1438        }
1439
162462
60485
        my $ticks = _rational_to_ticks($event->duration, $divisions);
1440
162462
123716
        my $type  = $NWC_TYPE_MAP{ $d->{base_dur} // '4th' } // 'quarter';
1441
1442        # Tie flags for this specific position key
1443
162462
240867
        (my $pk = $d->{nwc_pos} // '0') =~ s/\^\z//;
1444
162462
77178
        my $tie_stop  = ($ev_ann->{tie_stop_keys}  // {})->{$pk};
1445
162462
73022
        my $tie_start = ($ev_ann->{tie_start_keys} // {})->{$pk};
1446
1447        # Slur flags: only the first note of a chord carries the arc endpoints
1448
162462
74667
        my $slur_start = !$is_chord_member && $ev_ann->{slur_start};
1449
162462
71391
        my $slur_stop  = !$is_chord_member && $ev_ann->{slur_stop};
1450
1451
162462
53998
        push @out, "${pad}<note>";
1452
162462
54909
        push @out, "${pad}${i}<chord/>" if $is_chord_member;
1453
162462
64954
        push @out, "${pad}${i}<grace/>" if $d->{is_grace};
1454
162462
55649
        push @out, "${pad}${i}<pitch>";
1455
162462
60908
        push @out, "${pad}${i}${i}<step>$pitch->{step}</step>";
1456
162462
57764
        push @out, "${pad}${i}${i}<alter>$pitch->{alter}</alter>" if $pitch->{alter};
1457
162462
59426
        push @out, "${pad}${i}${i}<octave>$pitch->{octave}</octave>";
1458
162462
51900
        push @out, "${pad}${i}</pitch>";
1459
162462
53386
        unless ($d->{is_grace}) {
1460
162461
60311
                push @out, "${pad}${i}<duration>$ticks</duration>";
1461                # <tie> elements come after <duration> and before <voice> per MusicXML schema
1462
162461
53212
                push @out, "${pad}${i}<tie type=\"stop\"/>"  if $tie_stop;
1463
162461
52846
                push @out, "${pad}${i}<tie type=\"start\"/>" if $tie_start;
1464        }
1465
162462
53765
        push @out, "${pad}${i}<voice>1</voice>";
1466
162462
54598
        push @out, "${pad}${i}<type>$type</type>";
1467
162462
83257
        push @out, "${pad}${i}<dot/>" for 1 .. ($d->{dots} // 0);
1468        push @out, "${pad}${i}<accidental>$pitch->{accidental}</accidental>"
1469
162462
55197
                if $pitch->{accidental};
1470
1471        # <notations> block
1472
162462
40684
        my @nots;
1473
162462
51907
        push @nots, "${pad}${i}${i}<tied type=\"stop\"/>"               if $tie_stop;
1474
162462
48926
        push @nots, "${pad}${i}${i}<tied type=\"start\"/>"              if $tie_start;
1475
162462
48901
        push @nots, "${pad}${i}${i}<slur number=\"$slur_stop\" type=\"stop\"/>"   if $slur_stop;
1476
162462
46566
        push @nots, "${pad}${i}${i}<slur number=\"$slur_start\" type=\"start\"/>" if $slur_start;
1477
162462
78972
        push @nots, "${pad}${i}${i}<fermata/>" if !$is_chord_member && $ev_ann->{fermata};
1478
1479        # Articulations: only on the first note of a chord (is_chord_member is false)
1480        my @artic_tokens = !$is_chord_member
1481
162462
37635
128010
51270
17211
76497
                ? grep { $_ ne 'Slur' } @{ $d->{articulations} // [] }
1482                : ();
1483
162462
53207
        if (@artic_tokens) {
1484
12854
3437
                my (@artic_els, @ornament_els, @direct_els);
1485
12854
3544
                for my $tok (@artic_tokens) {
1486
13029
7064
                        my $map = $ARTICULATION_MAP{$tok};
1487
13029
17475
                        if (!defined $map) {
1488
1
1
                                carp _fmt_msg('warn_unsupported_art', $tok);
1489
1
3
                                next;
1490                        }
1491
13028
13026
6183
19684
                        if    ($map->{group} eq 'articulations') { push @artic_els,   $map->{element} }
1492
1
4
                        elsif ($map->{group} eq 'ornaments')     { push @ornament_els, $map->{element} }
1493
1
3
                        else                                     { push @direct_els,   $map->{element} }
1494                }
1495
12854
18669
                if (@artic_els) {
1496
12851
5286
                        push @nots, "${pad}${i}${i}<articulations>";
1497
12851
6096
                        push @nots, "${pad}${i}${i}${i}<$_/>" for @artic_els;
1498
12851
4872
                        push @nots, "${pad}${i}${i}</articulations>";
1499                }
1500
12854
4441
                if (@ornament_els) {
1501
1
1
                        push @nots, "${pad}${i}${i}<ornaments>";
1502
1
2
                        push @nots, "${pad}${i}${i}${i}<$_/>" for @ornament_els;
1503
1
0
                        push @nots, "${pad}${i}${i}</ornaments>";
1504                }
1505
12854
4872
                push @nots, "${pad}${i}${i}<$_/>" for @direct_els;
1506        }
1507
1508
162462
49482
        if (@nots) {
1509
33545
11953
                push @out, "${pad}${i}<notations>";
1510
33545
10009
                push @out, @nots;
1511
33545
12031
                push @out, "${pad}${i}</notations>";
1512        }
1513
1514        # Lyrics: only on the first note of a chord (is_chord_member is false).
1515
162462
53179
        unless ($is_chord_member) {
1516
128010
128010
30713
66499
                for my $lyric (@{ $ev_ann->{lyrics} // [] }) {
1517
0
0
                        my $num      = $lyric->{verse}    // 1;
1518
0
0
                        my $syllabic = $lyric->{syllabic} // 'single';
1519
0
0
                        my $text     = _xml_escape($lyric->{text} // '');
1520
0
0
                        push @out, "${pad}${i}<lyric number=\"$num\">";
1521
0
0
                        push @out, "${pad}${i}${i}<syllabic>$syllabic</syllabic>";
1522
0
0
                        push @out, "${pad}${i}${i}<text>$text</text>";
1523
0
0
                        push @out, "${pad}${i}</lyric>";
1524                }
1525        }
1526
1527
162462
59895
        push @out, "${pad}</note>";
1528
162462
156131
        return @out;
1529}
1530
1531sub _emit_rest_event {
1532
23463
8251
        my ($self, $event, $divisions, $pad, $ev_ann) = @_;
1533
23463
8007
        $ev_ann //= {};
1534
23463
8602
        my $d   = $event->data;
1535
23463
6344
        my @out;
1536
23463
6607
        my $i   = $self->{_indent};
1537
1538
23463
7827
        my $ticks = _rational_to_ticks($event->duration, $divisions);
1539
23463
22590
        my $type  = $NWC_TYPE_MAP{ $d->{base_dur} // '4th' } // 'quarter';
1540
1541
23463
33305
        push @out, "${pad}<note>";
1542
23463
8489
        push @out, "${pad}${i}<rest/>";
1543
23463
8916
        push @out, "${pad}${i}<duration>$ticks</duration>";
1544
23463
7843
        push @out, "${pad}${i}<voice>1</voice>";
1545
23463
8390
        push @out, "${pad}${i}<type>$type</type>";
1546
23463
13501
        push @out, "${pad}${i}<dot/>" for 1 .. ($d->{dots} // 0);
1547
23463
8161
        if ($ev_ann->{fermata}) {
1548
2
3
                push @out, "${pad}${i}<notations>";
1549
2
4
                push @out, "${pad}${i}${i}<fermata/>";
1550
2
2
                push @out, "${pad}${i}</notations>";
1551        }
1552
23463
8145
        push @out, "${pad}</note>";
1553
23463
14974
        return @out;
1554}
1555
1556sub _emit_chord_event {
1557
24911
10058
        my ($self, $event, $clef, $key_fifths, $divisions, $pad, $ev_ann, $bar_accidentals) = @_;
1558
24911
8630
        $ev_ann          //= {};
1559
24911
7777
        $bar_accidentals //= {};
1560
24911
8246
        my $d     = $event->data;
1561
24911
6933
        my @out;
1562
1563
24911
17942
        my $positions = $d->{nwc_positions} // ['0'];
1564
24911
6197
        my $first     = 1;
1565
1566
24911
11282
        for my $pos_str (@$positions) {
1567                # Build a per-position annotation that inherits slur flags (first note only)
1568                # and picks the tie flags for this specific position key.
1569
59363
22965
                (my $pk = $pos_str) =~ s/\^\z//;
1570                my %pos_ann = (
1571                        tie_stop_keys  => { $pk => ($ev_ann->{tie_stop_keys}  // {})->{$pk} // 0 },
1572                        tie_start_keys => { $pk => ($ev_ann->{tie_start_keys} // {})->{$pk} // 0 },
1573                        ($first ? (
1574                                slur_start => $ev_ann->{slur_start},
1575                                slur_stop  => $ev_ann->{slur_stop},
1576                                lyrics     => $ev_ann->{lyrics},
1577                                fermata    => $ev_ann->{fermata},
1578
59363
88336
                        ) : ()),
1579                );
1580
59363
23997
                push @out, $self->_emit_note_event(
1581                        _chord_note_event($event, $pos_str),
1582                        $clef, $key_fifths, $divisions, $pad, !$first, \%pos_ann, $bar_accidentals
1583                );
1584
59363
49765
                $first = 0;
1585        }
1586
24911
32864
        return @out;
1587}
1588
1589# Build a synthetic Note event for one pitch member of a Chord.
1590# Uses Event->new so that validation, rational reduction and the
1591# public accessor contract are all preserved.
1592sub _chord_note_event {
1593
59363
17925
        my ($chord_event, $pos_str) = @_;
1594
59363
21343
        my $d = $chord_event->data;
1595        return Music::NWC2MusicXML::Event->new(
1596                type     => 'Note',
1597                duration => $chord_event->duration,
1598                data     => {
1599                        nwc_pos       => $pos_str,
1600                        base_dur      => $d->{base_dur},
1601                        dots          => $d->{dots} // 0,
1602                        articulations => $d->{articulations} // [],
1603                        is_grace      => $d->{is_grace},
1604                },
1605
59363
22295
        );
1606}
1607
1608# ---------------------------------------------------------------------------
1609# Private: pitch conversion
1610# ---------------------------------------------------------------------------
1611
1612# Convert an NWC position string (e.g. "#-6", "b3", "-9^") to a MusicXML
1613# pitch descriptor { step, octave, alter, accidental }.
1614#
1615# Position 0 = middle line (3rd from bottom) of the staff (clef-specific).
1616# Negative positions go DOWN the staff; positive positions go UP.
1617# Formula: diatonic_index = octave*7 + step_index + pos_num
1618sub _pos_to_pitch {
1619
162479
74752
        my ($self, $pos_str, $clef, $key_fifths) = @_;
1620
162479
53948
        $pos_str    //= '0';
1621
162479
47349
        $clef       //= 'Treble';
1622
162479
49791
        $key_fifths //= 0;
1623
1624        # Parse: optional accidental prefix + signed integer + optional tie marker
1625
162479
46132
        my ($acc_prefix, $pos_num) = ('', 0);
1626
162479
88039
        if ($pos_str =~ /\A([#bnx]*)(-?\d+)\^?\z/) {
1627
162479
68680
                ($acc_prefix, $pos_num) = ($1, $2 + 0);
1628        }
1629
1630
162479
77977
        my $ref      = $CLEF_REF{$clef} // $CLEF_REF{Treble};
1631
162479
238912
        my $index    = $ref->[1] * 7 + $ref->[0] + $pos_num;
1632
1633
162479
358565
        my $octave   = floor($index / 7);
1634
162479
47866
        my $step_i   = $index - $octave * 7;
1635
1636
162479
65868
        my $step     = $STEP_NAMES[$step_i];
1637
162479
203788
        my $key_alt  = _key_alter_for_step($step_i, $key_fifths);
1638
1639        # Dispatch: known prefixes from %ACCIDENTAL_MAP; empty/unknown -> key-sig alter.
1640
162479
73774
        my ($alter, $accidental);
1641
162479
74066
        if (my $acc = $ACCIDENTAL_MAP{$acc_prefix}) {
1642
10828
16047
                ($alter, $accidental) = @$acc;
1643        } else {
1644
151651
181369
                $alter      = $key_alt;
1645
151651
37733
                $accidental = undef;
1646        }
1647
1648
162479
121679
        return { step => $step, octave => $octave, alter => $alter, accidental => $accidental };
1649}
1650
1651sub _key_alter_for_step {
1652
162479
49348
        my ($step_i, $key_fifths) = @_;
1653
162479
54145
        return 0 unless $key_fifths;
1654
1655        # P1: $key_fifths != 0 (guarded above). P2: sharp/flat differ only in sign+table.
1656
131694
46495
        my ($sign, $steps) = $key_fifths > 0
1657                ? (1, \@SHARP_STEPS)
1658                : (-1, \@FLAT_STEPS);
1659
131694
42646
        my $n = abs($key_fifths) > 7 ? 7 : abs($key_fifths);
1660
131694
39779
        for my $k (0 .. $n - 1) {
1661
261627
228529
                return $sign if $steps->[$k] == $step_i;
1662        }
1663
102864
130785
        return 0;
1664}
1665
1666sub _rational_to_ticks {
1667
185930
55136
        my ($rational, $divisions) = @_;
1668
185930
123854
        return int($rational->[0] * $divisions / $rational->[1] + 0.5);
1669}
1670
1671sub _emit_tempo {
1672
155
176
        my ($self, $bpm, $base, $text, $pad) = @_;
1673
155
96
        my $i = $self->{_indent};
1674
155
121
        $bpm  //= 120;
1675
155
133
        $base //= 'Quarter';
1676
155
121
        $text //= '';
1677
1678
155
167
        my $map       = $TEMPO_BASE_MAP{$base} // $TEMPO_BASE_MAP{Quarter};
1679
155
447
        my $unit      = $map->{unit};
1680
155
354
        my $dot       = $map->{dot};
1681
155
300
        my $sound_bpm = int($bpm * $map->{factor} + 0.5);
1682
1683
155
347
        my @out;
1684
155
166
        push @out, "${pad}<direction placement=\"above\">";
1685
155
143
        if (length $text) {
1686
1
1
                push @out, "${pad}${i}<direction-type>";
1687
1
1
                push @out, "${pad}${i}${i}<words font-style=\"italic\">" . _xml_escape($text) . " </words>";
1688
1
1
                push @out, "${pad}${i}</direction-type>";
1689        }
1690
155
631
        my $parens = length($text) ? 'yes' : 'no';
1691
155
150
        push @out, "${pad}${i}<direction-type>";
1692
155
186
        push @out, "${pad}${i}${i}<metronome parentheses=\"$parens\">";
1693
155
202
        push @out, "${pad}${i}${i}${i}<beat-unit>$unit</beat-unit>";
1694
155
111
        push @out, "${pad}${i}${i}${i}<beat-unit-dot/>" if $dot;
1695
155
261
        push @out, "${pad}${i}${i}${i}<per-minute>$bpm</per-minute>";
1696
155
149
        push @out, "${pad}${i}${i}</metronome>";
1697
155
120
        push @out, "${pad}${i}</direction-type>";
1698
155
141
        push @out, "${pad}${i}<sound tempo=\"$sound_bpm\"/>";
1699
155
112
        push @out, "${pad}</direction>";
1700
155
209
        return @out;
1701}
1702
1703sub _emit_dynamic {
1704
2907
4433
        my ($self, $marking, $placement, $pad) = @_;
1705
2907
1071
        my $i = $self->{_indent};
1706
2907
1188
        $marking   //= '';
1707
2907
1091
        $placement //= '';
1708
1709
2907
1878
        unless (exists $DYNAMIC_MAP{$marking}) {
1710
2
10
                carp _fmt_msg('warn_unknown_dynamic', $marking) if $marking ne '';
1711
2
182
                return ();
1712        }
1713
1714
2905
5752
        my $place = (lc($placement) eq 'above') ? 'above' : 'below';
1715
2905
859
        my @out;
1716
2905
1392
        push @out, "${pad}<direction placement=\"$place\">";
1717
2905
1148
        push @out, "${pad}${i}<direction-type>";
1718
2905
1208
        push @out, "${pad}${i}${i}<dynamics>";
1719
2905
1448
        push @out, "${pad}${i}${i}${i}<$marking/>";
1720
2905
1266
        push @out, "${pad}${i}${i}</dynamics>";
1721
2905
1269
        push @out, "${pad}${i}</direction-type>";
1722
2905
1233
        my $dyn_vel = $self->{_dyn_vel};
1723
2905
2649
        if (defined $dyn_vel && defined $dyn_vel->{$marking}) {
1724
2901
1532
                my $pct = int($dyn_vel->{$marking} * 100 / 127 + 0.5);
1725
2901
1631
                push @out, "${pad}${i}<sound dynamics=\"$pct\"/>";
1726        }
1727
2905
1247
        push @out, "${pad}</direction>";
1728
2905
3122
        return @out;
1729}
1730
1731# Map hairpin-annotation style -> MusicXML wedge type (used by _annotate_wedges output on notes,
1732# NOT for standalone DynVariance events -- those are text markings handled by _emit_dyn_variance).
1733Readonly::Hash my %WEDGE_MAP => (
1734        Crescendo  => 'crescendo',
1735        Diminuendo => 'diminuendo',
1736        crescOff   => 'stop',
1737);
1738
1739sub _emit_wedge {
1740
2275
2886
        my ($self, $style, $placement, $pad) = @_;
1741
2275
1036
        my $i = $self->{_indent};
1742
2275
1315
        $style     //= '';
1743
2275
2023
        $placement //= '';
1744
1745
2275
1464
        my $wedge_type = $WEDGE_MAP{$style};
1746
2275
4041
        return () unless defined $wedge_type;
1747
1748
2274
1276
        my $place = (lc($placement) eq 'above') ? 'above' : 'below';
1749
2274
695
        my @out;
1750
2274
1192
        push @out, "${pad}<direction placement=\"$place\">";
1751
2274
1076
        push @out, "${pad}${i}<direction-type>";
1752
2274
1175
        push @out, "${pad}${i}${i}<wedge type=\"$wedge_type\" number=\"1\"/>";
1753
2274
995
        push @out, "${pad}${i}</direction-type>";
1754
2274
910
        push @out, "${pad}</direction>";
1755
2274
1693
        return @out;
1756}
1757
1758# Map NWC DynVariance style -> display text. crescOff has no text representation.
1759Readonly::Hash my %DYN_VARIANCE_TEXT => (
1760        Crescendo  => 'cresc.',
1761        Diminuendo => 'dim.',
1762);
1763
1764sub _emit_dyn_variance {
1765
3
2
        my ($self, $style, $placement, $pad) = @_;
1766
3
2
        $style     //= '';
1767
3
2
        $placement //= 'below';
1768
3
1
        my $i = $self->{_indent};
1769
1770
3
2
        my $text = $DYN_VARIANCE_TEXT{$style};
1771
3
6
        return () unless defined $text;
1772
1773
2
2
        my $place = (lc($placement) eq 'above') ? 'above' : 'below';
1774
2
1
        my @out;
1775
2
4
        push @out, "${pad}<direction placement=\"$place\">";
1776
2
1
        push @out, "${pad}${i}<direction-type>";
1777
2
3
        push @out, "${pad}${i}${i}<words font-style=\"italic\">$text</words>";
1778
2
0
        push @out, "${pad}${i}</direction-type>";
1779
2
2
        push @out, "${pad}</direction>";
1780
2
2
        return @out;
1781}
1782
1783Readonly::Hash my %TEMPO_VARIANCE_TEXT => (
1784        Accelerando      => 'accel.',
1785        Ritardando       => 'rit.',
1786        Ritenuto         => 'rit.',
1787        Rallentando      => 'rall.',
1788        RitardandoToTempo => 'a tempo',
1789        Stringendo       => 'string.',
1790        Breath           => "\x{2019}",   # right single quotation mark used as breath comma
1791        Caesura          => '//',
1792);
1793
1794sub _emit_tempo_variance {
1795
124
136
        my ($self, $style, $placement, $pad) = @_;
1796
124
103
        $style     //= '';
1797
124
74
        $placement //= 'above';
1798
124
71
        my $i = $self->{_indent};
1799
1800
124
147
        my $text = _xml_escape($TEMPO_VARIANCE_TEXT{$style} // $style);
1801
124
153
        return () unless length $text;
1802
1803
124
160
        my $place = (lc($placement) eq 'above') ? 'above' : 'below';
1804
124
64
        my @out;
1805
124
143
        push @out, "${pad}<direction placement=\"$place\">";
1806
124
126
        push @out, "${pad}${i}<direction-type>";
1807
124
122
        push @out, "${pad}${i}${i}<words font-style=\"italic\">$text</words>";
1808
124
78
        push @out, "${pad}${i}</direction-type>";
1809
124
103
        push @out, "${pad}</direction>";
1810
124
133
        return @out;
1811}
1812
1813sub _emit_text_direction {
1814
0
0
        my ($self, $text, $placement, $pad) = @_;
1815
0
0
        $text      //= '';
1816
0
0
        $placement //= '';
1817
0
0
        return () unless length $text;
1818
0
0
        my $i     = $self->{_indent};
1819
0
0
        my $place = (lc($placement) eq 'above') ? 'above' : 'below';
1820
0
0
        my @out;
1821
0
0
        push @out, "${pad}<direction placement=\"$place\">";
1822
0
0
        push @out, "${pad}${i}<direction-type>";
1823
0
0
        push @out, "${pad}${i}${i}<words>" . _xml_escape($text) . "</words>";
1824
0
0
        push @out, "${pad}${i}</direction-type>";
1825
0
0
        push @out, "${pad}</direction>";
1826
0
0
        return @out;
1827}
1828
1829sub _emit_flow_control {
1830
0
0
        my ($self, $directive, $pad) = @_;
1831
0
0
        $directive //= '';
1832
0
0
        my $map = $FLOW_CONTROL_MAP{$directive};
1833
0
0
        return () unless defined $map;
1834
0
0
        my $i = $self->{_indent};
1835
0
0
        my @out;
1836
0
0
        push @out, "${pad}<direction placement=\"above\">";
1837
0
0
        if (defined $map->{element}) {
1838
0
0
                push @out, "${pad}${i}<direction-type>";
1839
0
0
                push @out, "${pad}${i}${i}<$map->{element}/>";
1840
0
0
                push @out, "${pad}${i}</direction-type>";
1841        }
1842
0
0
        if (defined $map->{words}) {
1843
0
0
                push @out, "${pad}${i}<direction-type>";
1844                push @out, "${pad}${i}${i}<words font-style=\"italic\">"
1845
0
0
                        . _xml_escape($map->{words}) . "</words>";
1846
0
0
                push @out, "${pad}${i}</direction-type>";
1847        }
1848
0
0
        push @out, "${pad}</direction>";
1849
0
0
        return @out;
1850}
1851
1852sub _emit_instrument_change {
1853
0
0
        my ($self, $name, $patch, $pad) = @_;
1854
0
0
        my $i    = $self->{_indent};
1855
0
0
        my $prog = ($patch // 0) + 1;
1856
0
0
        my $text = $name ? _xml_escape($name) : "Program $prog";
1857
0
0
        my @out;
1858
0
0
        push @out, "${pad}<direction placement=\"above\">";
1859
0
0
        push @out, "${pad}${i}<direction-type>";
1860
0
0
        push @out, "${pad}${i}${i}<other-direction>$text</other-direction>";
1861
0
0
        push @out, "${pad}${i}</direction-type>";
1862
0
0
        push @out, "${pad}</direction>";
1863
0
0
        return @out;
1864}
1865
1866sub _emit_time_change {
1867
2320
1106
        my ($self, $ts_data, $pad) = @_;
1868
2320
914
        my @out;
1869
2320
902
        my $i = $self->{_indent};
1870
2320
1799
        my $beats     = $ts_data->{beats}     // 4;
1871
2320
1566
        my $beat_type = $ts_data->{beat_type} // 4;
1872
1873
2320
1246
        push @out, "${pad}<attributes>";
1874
2320
1076
        push @out, "${pad}${i}<time>";
1875
2320
1207
        push @out, "${pad}${i}${i}<beats>$beats</beats>";
1876
2320
1282
        push @out, "${pad}${i}${i}<beat-type>$beat_type</beat-type>";
1877
2320
952
        push @out, "${pad}${i}</time>";
1878
2320
937
        push @out, "${pad}</attributes>";
1879
2320
1955
        return @out;
1880}
1881
1882sub _emit_clef_change {
1883
206
207
        my ($self, $clef_name, $pad) = @_;
1884
206
121
        my @out;
1885
206
153
        my $i    = $self->{_indent};
1886
206
238
        my $clef = $CLEF_MAP{$clef_name} // $CLEF_MAP{Treble};
1887
1888
206
644
        push @out, "${pad}<attributes>";
1889
206
181
        push @out, "${pad}${i}<clef>";
1890
206
276
        push @out, "${pad}${i}${i}<sign>$clef->{sign}</sign>";
1891        push @out, "${pad}${i}${i}<line>$clef->{line}</line>"
1892
206
715
                if defined $clef->{line};
1893
206
1007
        push @out, "${pad}${i}</clef>";
1894
206
179
        push @out, "${pad}</attributes>";
1895
206
257
        return @out;
1896}
1897
1898sub _emit_key_change {
1899
581
535
        my ($self, $key_data, $pad) = @_;
1900
581
288
        my @out;
1901
581
377
        my $i      = $self->{_indent};
1902
581
566
        my $fifths = $key_data->{fifths} // 0;
1903
1904
581
544
        push @out, "${pad}<attributes>";
1905
581
581
        push @out, "${pad}${i}<key>";
1906
581
589
        push @out, "${pad}${i}${i}<fifths>$fifths</fifths>";
1907
581
473
        push @out, "${pad}${i}</key>";
1908
581
357
        push @out, "${pad}</attributes>";
1909
581
586
        return @out;
1910}
1911
1912sub _emit_attributes {
1913
396
317
        my ($self, $staff, $divisions, $pad) = @_;
1914
396
145
        my @out;
1915
396
253
        my $i = $self->{_indent};
1916
1917
396
275
        push @out, "${pad}<attributes>";
1918
396
312
        push @out, "${pad}${i}<divisions>$divisions</divisions>";
1919
1920
396
364
        if (my $key = $staff->initial_key) {
1921
221
202
                push @out, "${pad}${i}<key>";
1922
221
315
                push @out, "${pad}${i}${i}<fifths>$key->{fifths}</fifths>";
1923
221
220
                push @out, "${pad}${i}</key>";
1924        }
1925
1926
396
379
        if (my $ts = $staff->initial_timesig) {
1927
219
178
                push @out, "${pad}${i}<time>";
1928
219
317
                push @out, "${pad}${i}${i}<beats>$ts->{beats}</beats>";
1929
219
266
                push @out, "${pad}${i}${i}<beat-type>$ts->{beat_type}</beat-type>";
1930
219
200
                push @out, "${pad}${i}</time>";
1931        }
1932
1933
396
319
        if (my $clef_name = $staff->initial_clef) {
1934
251
227
                push @out, "${pad}${i}<clef>";
1935
251
648
                my $clef = $CLEF_MAP{$clef_name};
1936
251
815
                unless (defined $clef) {
1937
1
0
                        carp _fmt_msg('warn_unknown_clef', $clef_name);
1938
1
181
                        $clef = $CLEF_MAP{Treble};
1939                }
1940
251
307
                push @out, "${pad}${i}${i}<sign>$clef->{sign}</sign>";
1941                push @out, "${pad}${i}${i}<line>$clef->{line}</line>"
1942
251
648
                        if defined $clef->{line};
1943
251
1192
                push @out, "${pad}${i}</clef>";
1944        }
1945
1946
396
326
        my $trans = ($staff->instrument // {})->{trans} // 0;
1947
396
253
        if ($trans) {
1948
0
0
                push @out, "${pad}${i}<transpose>";
1949
0
0
                push @out, "${pad}${i}${i}<chromatic>$trans</chromatic>";
1950
0
0
                push @out, "${pad}${i}</transpose>";
1951        }
1952
1953
396
288
        push @out, "${pad}</attributes>";
1954
396
464
        return @out;
1955}
1956
1957# ---------------------------------------------------------------------------
1958# Private: divisions calculation
1959# ---------------------------------------------------------------------------
1960
1961# Strategy: collect the denominator of every rational duration encountered
1962# across all staves, then compute their LCM.  The divisions value is that LCM,
1963# guaranteeing exact integer representation for every duration tick count.
1964
1965sub _calculate_divisions {
1966
198
129
        my ($self, $score) = @_;
1967
1968
198
96
        my %seen_denoms;
1969
198
198
88
176
        for my $staff (@{ $score->staves }) {
1970
399
399
123
323
                for my $event (@{ $staff->events }) {
1971
203087
94444
                        my $d = $event->duration->[1];
1972
203087
91390
                        $seen_denoms{$d} = 1 if $d > 0;
1973                }
1974        }
1975
1976
198
145
        return $DEFAULT_DIVISIONS unless %seen_denoms;
1977
1978
193
150
        my @denoms = keys %seen_denoms;
1979
193
99
        my $lcm = $denoms[0];
1980
193
242
        for my $d (@denoms[1..$#denoms]) {
1981
64
96
                $lcm = _lcm($lcm, $d);
1982        }
1983
1984
193
177
        return $lcm;
1985}
1986
1987# ---------------------------------------------------------------------------
1988# Private: XML helpers
1989# ---------------------------------------------------------------------------
1990
1991# Pre-built table for single-pass XML character escaping.
1992# Covers the five XML-special ASCII chars; non-ASCII get numeric char refs.
1993Readonly::Hash my %XML_ESCAPE_MAP => (
1994        '&'  => '&amp;',
1995        '<'  => '&lt;',
1996        '>'  => '&gt;',
1997        '"'  => '&quot;',
1998        "'"  => '&apos;',
1999);
2000
2001sub _xml_escape {
2002
1116
11513
        my ($s) = @_;
2003
1116
503
        return '' unless defined $s;
2004        # Pass 1: strip XML 1.0 illegal control bytes (cannot appear even as &#N;).
2005
1113
632
        $s =~ s/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]//g;
2006        # Pass 2: escape XML specials and numeric-ref non-ASCII in a single scan.
2007
1113
251
1079
558
        $s =~ s/([&<>"']|[^\x00-\x7F])/$XML_ESCAPE_MAP{$1} \/\/ sprintf "&#%d;", ord($1)/ge;
2008
1113
1254
        return $s;
2009}
2010
2011sub _lcm {
2012
67
62
        my ($a, $b) = @_;
2013
67
78
        return $a / _gcd($a, $b) * $b;
2014}
2015
2016sub _gcd {
2017
69
693
        my ($a, $b) = @_;
2018
69
139
        ($a, $b) = ($b, $a % $b) while $b;
2019
69
115
        return $a;
2020}
2021
2022sub _fmt_msg {
2023
19
11
        my ($key, @args) = @_;
2024
19
29
        croak "Unknown message key: $key" unless exists $MESSAGES{$key};
2025
19
59
        return sprintf $MESSAGES{$key}, @args;
2026}
2027
20281;
2029