File Coverage

File:blib/lib/Music/NWC2MusicXML/Parser.pm
Coverage:82.1%

linestmtbrancondsubtimecode
1package Music::NWC2MusicXML::Parser;
2
3
15
15
15
112050
7
75
use strict;
4
15
15
15
9
17
194
use warnings;
5
15
15
15
224
6767
31
use autodie qw(:all);
6
7our $VERSION = '0.002.4';
8
9
15
15
15
25090
13
203
use Carp qw(croak carp);
10
15
15
15
302
1844
158
use Readonly;
11
15
15
15
479
11211
158
use Params::Validate::Strict qw(validate_strict);
12
15
15
15
283
3721
115
use Params::Get;
13
15
15
15
1467
10
101
use Music::NWC2MusicXML::Score;
14
15
15
15
15
5
49
use Music::NWC2MusicXML::Staff;
15
15
15
15
9
6
15272
use Music::NWC2MusicXML::Event;
16
17# ---------------------------------------------------------------------------
18# Constants
19# ---------------------------------------------------------------------------
20
21# Maximum number of records to parse before deciding the file is pathological.
22Readonly::Scalar my $MAX_RECORDS => 1_000_000;
23
24# The NWCTXT header line prefix
25Readonly::Scalar my $HEADER_PREFIX => '!NoteWorthyComposer(';
26
27# Dynamic markings recognised by NWC
28Readonly::Hash my %VALID_DYNAMICS => map { $_ => 1 }
29        qw(pppp ppp pp p mp mf f ff fff ffff);
30
31# Clef names recognised by NWC and their canonical spellings
32Readonly::Hash my %VALID_CLEFS => map { $_ => 1 }
33        qw(Treble Bass Alto Tenor Percussion Tab);
34
35# NWC lists key-signature accidentals in the standard circle-of-fifths order.
36# Signature:F#,C#  = 2 sharps = D major  (fifths +2)
37# Signature:Bb     = 1 flat   = F major  (fifths -1)
38# We compute the fifths value by counting the accidentals rather than using
39# a lookup table, which handles any combination and avoids the common mistake
40# of confusing the tonic name with the signature accidental list.
41
42# The 'C' signature token means no accidentals (C major / A minor = fifths 0).
43Readonly::Scalar my $KEY_SIG_NATURAL => 'C';
44
45# Pitch step letters recognised in NWC note records
46Readonly::Hash my %VALID_STEPS => map { $_ => 1 } qw(A B C D E F G);
47
48Readonly::Hash my %MESSAGES => (
49        error_empty_input     => 'NWCTXT input is empty or undefined',
50        error_no_header       => 'NWCTXT does not begin with expected header',
51        error_too_many_records=> 'Input exceeds maximum record limit (%d)',
52        error_bad_record      => 'Malformed NWCTXT record at line %d: %s',
53        error_no_staff        => 'Musical event %s encountered before any AddStaff record',
54        error_internal        => 'Internal error: %s',
55        warn_unknown_record   => 'Unknown NWCTXT record type %s at line %d -- stored as UnsupportedEvent',
56        warn_bad_value        => 'Unrecognised value for %s: %s at line %d',
57);
58
59 - 124
=head1 NAME

Music::NWC2MusicXML::Parser - NWCTXT text parser producing an internal score
representation.

=head1 VERSION

0.002.4

=head1 SYNOPSIS

    use Music::NWC2MusicXML::Parser;

    my $parser = Music::NWC2MusicXML::Parser->new;
    my $score  = $parser->parse($nwctxt);

=head1 DESCRIPTION

C<Music::NWC2MusicXML::Parser> accepts the NWCTXT string produced by
C<Music::NWC2MusicXML::NWC> and returns a C<Music::NWC2MusicXML::Score> object.

The internal representation is completely independent of MusicXML so that
parsing and MusicXML generation can be tested separately.

=head2 NWCTXT record structure

Each record occupies one line and begins with C<|>:

    |RecordType|Field1:Value1|Field2:Value2|...

Fields are pipe-delimited.  Values may be:

=over 4

=item * unquoted single tokens

=item * double-quoted strings (which may contain C<|> and C<:>)

=item * comma-separated lists

=back

The parser does B<not> use C<split(/[|]/)> because quoted values and escape
sequences require a proper tokeniser.

B<Record type aliases>: some NWC versions export C<DynamicVariance> where
others write the abbreviated C<DynVariance>; the parser accepts both and emits
the same C<DynVariance> event in either case.

B<Text event placement>: C<Text> records carry an optional C<Placement:>
field.  When it is absent the vertical position is inferred from the C<Pos:>
field: a non-negative C<Pos> value sets placement to C<'Above'>; a negative
value sets it to C<'Below'>.  This handles common NWC files where performance
directions such as "Slightly faster" are positioned above the staff via a
positive C<Pos> number with no explicit C<Placement:> field.

B<EndingBar normalisation>: NWC C<StaffProperties> stores the ending barline
style as a multi-word string (e.g. C<"Section Close">).  The parser strips
internal spaces on ingestion so the value matches the compact style tokens used
by C<Music::NWC2MusicXML::MusicXML> (e.g. C<'SectionClose'>).  When NWC emits
two C<StaffProperties> records for a staff (the second carrying channel and
volume data), only the record that actually contains an C<EndingBar:> field
updates C<_ending_bar>; the second record is not allowed to clobber the value
set by the first.

=cut
125
126# ---------------------------------------------------------------------------
127# new
128# ---------------------------------------------------------------------------
129
130 - 165
=head2 new

Construct a parser.

=head3 Arguments

Named parameters:

=over 4

=item C<diagnostics> -- a C<Music::NWC2MusicXML::Diagnostics> instance (optional).

=back

=head3 Returns

Blessed C<Music::NWC2MusicXML::Parser> object.

=head3 API SPECIFICATION

=head4 Input

    diagnostics : Music::NWC2MusicXML::Diagnostics  (optional)

=head4 Output

    Music::NWC2MusicXML::Parser object

=head3 FORMAL SPECIFICATION

 [ParserInit]
   diagnostics : Diagnostics

 (placeholder -- populate with Z calculus as implementation matures)

=cut
166
167sub new {
168
229
814720
        my ($class, %input) = @_;
169
229
319
        my $args = validate_strict(
170                schema => {
171                        diagnostics => { type => 'object', optional => 1 },
172                },
173                input => \%input,
174        );
175
229
7027
        croak $@ unless defined $args;
176
177        my $self = bless {
178                _diagnostics => $args->{diagnostics},
179
229
310
                _score       => undef,
180                _line_no     => 0,
181                _record_count => 0,
182        }, $class;
183
184
229
347
        return $self;
185}
186
187# ---------------------------------------------------------------------------
188# Public: parse
189# ---------------------------------------------------------------------------
190
191 - 262
=head2 parse

Parse a complete NWCTXT string and return the corresponding
C<Music::NWC2MusicXML::Score>.

=head3 Purpose

This is the primary entry point.  It iterates over each line of the NWCTXT,
dispatches to a type-specific handler, and accumulates the results into a
Score object.

=head3 Arguments

=over 4

=item C<$nwctxt> -- scalar string containing the full NWCTXT representation
(required).

=back

=head3 Returns

A C<Music::NWC2MusicXML::Score> object.

=head3 Side Effects

Croaks on fatal structural errors (e.g. missing header).
Issues warnings via C<diagnostics> for non-fatal issues (e.g. unknown records).

=head3 Usage Example

    my $score = Music::NWC2MusicXML::Parser->new->parse($nwctxt);

=head3 API SPECIFICATION

=head4 Input

    $nwctxt : SCALAR (UTF-8, required)
                -- Valid domain: non-empty string beginning with the line
                --   '!NoteWorthyComposer(<version>)' followed by pipe-delimited records
                -- Invalid partitions:
                --   undef or '' -> croaks error_empty_input
                --   non-empty but missing header -> croaks error_no_header
                -- Boundary: MAX_RECORDS = 1,000,000 pipe-delimited records
                --   At exactly 1,000,000 records: accepted
                --   At 1,000,001+ records: croaks error_too_many_records

=head4 Output

    Music::NWC2MusicXML::Score

=head3 MESSAGES

| Code                  | Meaning                                  | Resolution                    |
|-----------------------|------------------------------------------|-------------------------------|
| error_empty_input     | Input is undef or zero-length            | Check NWC decoder output      |
| error_no_header       | Header line not found                    | Verify NWC binary decoder     |
| error_too_many_records| Record count exceeds MAX_RECORDS         | Input may be malicious/corrupt|
| error_bad_record      | Record cannot be tokenised               | File may be corrupt           |
| error_no_staff        | Event seen before first AddStaff         | NWCTXT may be truncated       |
| warn_unknown_record   | Unknown record type stored as Unsupported| New NWC version may add types |

=head3 FORMAL SPECIFICATION

 [Parse]
   nwctxt? : NWCTXT
   ----------
   score!  : Score

 (placeholder)

=cut
263
264sub parse {
265
222
2177
        my ($self, $nwctxt) = @_;
266
267
222
361
        croak _fmt_msg('error_empty_input')
268                unless defined $nwctxt && length $nwctxt;
269
270        # Initialise fresh parse state for each call, enabling object reuse.
271
204
365
        $self->{_score}        = Music::NWC2MusicXML::Score->new;
272
204
127
        $self->{_line_no}      = 0;
273
204
107
        $self->{_record_count} = 0;
274
275
204
188949
        my @lines = split /\r?\n/, $nwctxt;
276
277        # First non-blank line must be the NWCTXT header
278
204
138
        my $header = shift @lines;
279
204
126
        $self->{_line_no}++;
280
281
204
392
        croak _fmt_msg('error_no_header')
282                unless defined $header
283                && index($header, $HEADER_PREFIX) == 0;
284
285        # Extract version string from header: !NoteWorthyComposer(2.751)
286
197
2141
        my ($version) = $header =~ /\A\Q$HEADER_PREFIX\E([^)]+)/;
287
197
161
        $self->{_score}{_nwc_version} = $version;
288
289        # Main parsing loop
290
197
148
        for my $line (@lines) {
291
1218157
330631
                $self->{_line_no}++;
292
1218157
665292
                next unless defined $line && length $line;
293
1218156
492378
                next if $line =~ /\A\s*\z/;    # blank lines are legal
294
295                croak _fmt_msg('error_too_many_records', $MAX_RECORDS)
296
1218155
401190
                        if ++$self->{_record_count} > $MAX_RECORDS;
297
298                # All NWCTXT records begin with '|'
299
1218154
511914
                next unless $line =~ /\A\|/;
300
301
1218100
378400
                $self->_dispatch_record($line);
302        }
303
304
195
30662
        return $self->{_score};
305}
306
307# ---------------------------------------------------------------------------
308# Package-level dispatch table -- built once at module load, not per call.
309# Subs are compile-time symbols; Readonly ensures the table is never mutated.
310# ---------------------------------------------------------------------------
311Readonly::Hash my %DISPATCH => (
312        SongInfo        => \&_handle_song_info,
313        PgSetup         => \&_handle_pg_setup,
314        PgMargins       => \&_handle_pg_setup,
315        Font            => \&_handle_font,
316        AddStaff        => \&_handle_add_staff,
317        StaffProperties => \&_handle_staff_properties,
318        StaffInstrument => \&_handle_staff_instrument,
319        Clef            => \&_handle_clef,
320        Key             => \&_handle_key,
321        TimeSig         => \&_handle_time_sig,
322        Tempo           => \&_handle_tempo,
323        Note            => \&_handle_note,
324        Rest            => \&_handle_rest,
325        Chord           => \&_handle_chord,
326        Bar             => \&_handle_bar,
327        Dynamic         => \&_handle_dynamic,
328        DynVariance        => \&_handle_dyn_variance,
329        DynamicVariance    => \&_handle_dyn_variance,
330        Text            => \&_handle_text,
331        Lyric           => \&_handle_lyric,
332        Tie             => \&_handle_tie,
333        Slur            => \&_handle_slur,
334        Beam            => \&_handle_beam,
335        Tuplet          => \&_handle_tuplet,
336        Instrument      => \&_handle_instrument_change,
337        FlowControl     => \&_handle_flow_control,
338        TempoVariance   => \&_handle_tempo_variance,
339        Spacer          => \&_handle_spacer,
340        RestChord       => \&_handle_rest_chord,
341);
342
343# ---------------------------------------------------------------------------
344# Private: record dispatcher
345# ---------------------------------------------------------------------------
346
347# Strategy: extract the record type (first field after the leading |), then
348# call a dedicated _handle_* method.  Unknown types produce UnsupportedEvent
349# records and a warning rather than aborting conversion.
350
351sub _dispatch_record {
352
1218100
375755
        my ($self, $line) = @_;
353
354
1218100
374493
        my $fields = $self->_tokenise_record($line);
355
1218100
658225
        return unless defined $fields && @$fields;
356
357
1218100
335289
        my $type = shift @$fields;
358
359
1218100
543556
        if (exists $DISPATCH{$type}) {
360
1218064
1666182
                $DISPATCH{$type}->($self, $fields);
361        } else {
362
36
1662
                my $staff = $self->{_score}->current_staff;
363
36
3005
                if (defined $staff) {
364                        # Unknown type within a staff: record and warn.
365
5
1992
                        $self->_warn_unknown($type);
366
5
840
                        $self->_append_event(Music::NWC2MusicXML::Event->new(
367                                type      => 'UnsupportedEvent',
368                                nwc_label => $type,
369                                data      => { raw => join('|', $type, @$fields) },
370                        ));
371                }
372                # else: score-level record (Editor, Font, PgMargins, ...) before any
373                # AddStaff -- silently skip; these are not musical events.
374        }
375}
376
377# ---------------------------------------------------------------------------
378# Private: tokeniser
379# ---------------------------------------------------------------------------
380
381# Strategy: walk character by character to handle quoted strings and escaped
382# characters correctly.  A naive split('|') would break on quoted values
383# that contain '|' or ':'.
384
385sub _tokenise_record {
386
1218103
329520
        my ($self, $line) = @_;
387
388
1218103
291940
        my @fields;
389
1218103
307318
        my $pos = 0;
390
1218103
339818
        my $len = length $line;
391
392        # Skip leading '|'
393
1218103
449248
        $pos++ if substr($line, 0, 1) eq '|';
394
395
1218103
298622
        my $current = '';
396
1218103
291448
        my $in_quote = 0;
397
398
1218103
378545
        while ($pos < $len) {
399
9613380
2463319
                my $ch = substr($line, $pos, 1);
400
401
9613380
2447862
                if ($in_quote) {
402
25937
9546
                        if ($ch eq '"') {
403
1275
380
                                $in_quote = 0;
404                        } elsif ($ch eq '\\' && $pos + 1 < $len) {
405                                # Escape sequence: consume next char literally
406
5
2
                                $pos++;
407
5
3
                                $current .= substr($line, $pos, 1);
408                        } else {
409
24657
5851
                                $current .= $ch;
410                        }
411                } else {
412
9587443
2957183
                        if ($ch eq '"') {
413
1275
367
                                $in_quote = 1;
414                        } elsif ($ch eq '|') {
415
1402677
412420
                                push @fields, $current;
416
1402677
347658
                                $current = '';
417                        } else {
418
8183491
1943450
                                $current .= $ch;
419                        }
420                }
421
9613380
2870150
                $pos++;
422        }
423
424
1218103
328127
        push @fields, $current;
425
426
1218103
385246
        return \@fields;
427}
428
429# Parse a field list (already tokenised) into a key => value hashref.
430# Fields have the form "Key:Value" or just "Value" for positional fields.
431sub _fields_to_hash {
432
1218036
348268
        my ($self, $fields_ref) = @_;
433
1218036
278085
        my %h;
434
1218036
336241
        for my $field (@$fields_ref) {
435
1402550
488635
                if ($field =~ /\A([^:]+):(.*)\z/) {
436
399290
170133
                        $h{$1} = $2;
437                } else {
438
1003260
337898
                        $h{_positional} = $field;
439                }
440        }
441
1218036
372552
        return \%h;
442}
443
444# ---------------------------------------------------------------------------
445# Private: metadata handlers
446# ---------------------------------------------------------------------------
447
448sub _handle_font {
449
360
460
        my ($self, $fields) = @_;
450
360
135
        my $h = $self->_fields_to_hash($fields);
451
360
1045
        push @{ $self->{_score}{_fonts} }, {
452                style    => $h->{Style}    // '',
453                typeface => $h->{Typeface} // '',
454                size     => defined $h->{Size} ? $h->{Size} + 0 : 0,
455                bold     => ($h->{Bold}   // 'N') eq 'Y' ? 1 : 0,
456
360
109
                italic   => ($h->{Italic} // 'N') eq 'Y' ? 1 : 0,
457        };
458}
459
460sub _handle_song_info {
461
106
282
        my ($self, $fields) = @_;
462
106
117
        my $h = $self->_fields_to_hash($fields);
463
106
129
        for my $key (qw(Title Author Lyricist Copyright Copyright1 Copyright2 Comments)) {
464                $self->{_score}->set_metadata_field($key, $h->{$key})
465
742
643
                        if exists $h->{$key};
466        }
467}
468
469sub _handle_pg_setup {
470
79
160
        my ($self, $fields) = @_;
471
79
46
        my $h  = $self->_fields_to_hash($fields);
472
79
99
        my $ps = $self->{_score}->page_setup;
473
79
337
        %$ps = (%$ps, %$h);
474}
475
476sub _handle_add_staff {
477
392
782
        my ($self, $fields) = @_;
478
392
279
        my $h = $self->_fields_to_hash($fields);
479        my $staff = Music::NWC2MusicXML::Staff->new(
480                name  => $h->{Name}  // 'Staff',
481
392
1040
                group => $h->{Group} // 'Standard',
482        );
483
392
483
        $self->{_score}->add_staff($staff);
484}
485
486sub _handle_staff_properties {
487
262
530
        my ($self, $fields) = @_;
488
262
158
        my $h = $self->_fields_to_hash($fields);
489
262
271
        my $staff = $self->_current_staff_or_croak('StaffProperties');
490
262
558
        $staff->{_visible}         = ($h->{Visible} // 'Y') eq 'Y' ? 1 : 0;
491
262
771
        $staff->{_lines}           = $h->{Lines} // 5;
492
262
272
        if (defined $h->{EndingBar}) {
493
130
486
                (my $ending_bar = $h->{EndingBar}) =~ s/\s+//g;
494
130
125
                $staff->{_ending_bar} = $ending_bar;
495        }
496
262
293
        $staff->{_channel}         = $h->{Channel} + 0 if defined $h->{Channel};
497
262
326
        $staff->{_with_next_staff} = $h->{WithNextStaff} if defined $h->{WithNextStaff};
498
262
286
        $staff->{_volume}          = $h->{Volume}    + 0 if defined $h->{Volume};
499
262
235
        $staff->{_stereo_pan}      = $h->{StereoPan} + 0 if defined $h->{StereoPan};
500
262
663
        $staff->{_muted}           = ($h->{Muted} // 'N') eq 'Y' ? 1 : 0;
501}
502
503sub _handle_staff_instrument {
504
141
355
        my ($self, $fields) = @_;
505
141
126
        my $h     = $self->_fields_to_hash($fields);
506
141
147
        my $staff = $self->_current_staff_or_croak('StaffInstrument');
507        $staff->{_instrument} = {
508                name  => $h->{Name}  // '',
509                patch => $h->{Patch} // 0,
510
141
618
                trans => $h->{Trans} // 0,
511        };
512
141
334
        if (defined $h->{DynVel} && length $h->{DynVel}) {
513
120
229
                my @keys = qw(ppp pp p mp mf f ff fff);
514
120
206
                my @vels = split /,/, $h->{DynVel};
515
120
141
                if (@vels == @keys) {
516
120
87
                        my %vel_map;
517
120
960
140
739
                        @vel_map{@keys} = map { $_ + 0 } @vels;
518
120
290
                        $staff->{_dyn_vel} = \%vel_map;
519                }
520        }
521}
522
523# ---------------------------------------------------------------------------
524# Private: initial-state handlers (clef/key/time -- before events begin)
525# ---------------------------------------------------------------------------
526
527sub _handle_clef {
528
417
1012
        my ($self, $fields) = @_;
529
417
354
        my $h    = $self->_fields_to_hash($fields);
530
417
531
        my $type = $h->{Type} // 'Treble';
531
532
417
469
        unless (exists $VALID_CLEFS{$type}) {
533
0
0
                carp _fmt_msg('warn_bad_value', 'Clef.Type', $type, $self->{_line_no});
534        }
535
536
417
1120
        my $staff = $self->_current_staff_or_croak('Clef');
537
417
453
        if (!$staff->has_notes) {
538
203
279
                $staff->set_initial_clef($type);
539        } else {
540
214
915
                $self->_append_event(Music::NWC2MusicXML::Event->new(
541                        type => 'Clef',
542                        data => { nwc_clef => $type },
543                ));
544        }
545}
546
547sub _handle_key {
548
789
2096
        my ($self, $fields) = @_;
549
789
730
        my $h   = $self->_fields_to_hash($fields);
550
789
918
        my $sig = $h->{Signature} // 'C';
551
552        my $key_data = {
553                signature => $sig,
554
789
1570
                tonic     => $h->{Tonic} // '',
555                fifths    => _fifths_from_signature($sig),
556        };
557
558
789
844
        my $staff = $self->_current_staff_or_croak('Key');
559
789
1051
        if (!$staff->has_notes) {
560
188
263
                $staff->set_initial_key($key_data);
561        } else {
562
601
2216
                $self->_append_event(Music::NWC2MusicXML::Event->new(
563                        type => 'Key',
564                        data => $key_data,
565                ));
566        }
567}
568
569sub _handle_time_sig {
570
2590
4031
        my ($self, $fields) = @_;
571
2590
1459
        my $h   = $self->_fields_to_hash($fields);
572
2590
1791
        my $sig = $h->{Signature} // '4/4';
573
574
2590
2628
        my ($beats, $beat_type) = $sig =~ m{\A(\d+)/(\d+)\z};
575
2590
2482
        unless (defined $beats && defined $beat_type) {
576
4
8
                carp _fmt_msg('warn_bad_value', 'TimeSig.Signature', $sig, $self->{_line_no});
577
4
563
                ($beats, $beat_type) = (4, 4);
578        }
579
580
2590
2277
        my $ts_data = { beats => $beats + 0, beat_type => $beat_type + 0 };
581
582
2590
1504
        my $staff = $self->_current_staff_or_croak('TimeSig');
583
2590
1961
        if (!$staff->has_notes) {
584
190
243
                $staff->set_initial_timesig($ts_data);
585        } else {
586
2400
5347
                $self->_append_event(Music::NWC2MusicXML::Event->new(
587                        type => 'TimeSig',
588                        data => $ts_data,
589                ));
590        }
591}
592
593# ---------------------------------------------------------------------------
594# Private: musical event handlers
595# ---------------------------------------------------------------------------
596
597sub _handle_tempo {
598
177
396
        my ($self, $fields) = @_;
599
177
140
        my $h = $self->_fields_to_hash($fields);
600        $self->_append_event(Music::NWC2MusicXML::Event->new(
601                type => 'Tempo',
602                data => {
603                        bpm  => $h->{Tempo} // 120,
604                        base => $h->{Base}  // 'Quarter',
605
177
1236
                        text => $h->{Text}  // '',
606                },
607        ));
608}
609
610sub _handle_note {
611
109651
129772
        my ($self, $fields) = @_;
612
109651
36018
        my $h = $self->_fields_to_hash($fields);
613
614
109651
46748
        my ($base_dur, $dots, $triplet, $artic, $is_grace) = _parse_dur_tokens($h->{Dur} // '4th');
615
109651
45740
        my $rational = Music::NWC2MusicXML::Event->rational_from_nwc_duration($base_dur, $dots);
616
617        $self->_append_event(Music::NWC2MusicXML::Event->new(
618                type     => 'Note',
619                duration => $rational,
620                data     => {
621                        nwc_pos       => $h->{Pos} // '0',
622                        base_dur      => $base_dur,
623                        dots          => $dots,
624                        triplet       => $triplet,
625                        articulations => $artic,
626                        is_grace      => $is_grace,
627
109651
60191
                        opts          => _parse_opts($h->{Opts}),
628                },
629        ));
630}
631
632sub _handle_rest {
633
24270
29558
        my ($self, $fields) = @_;
634
24270
8353
        my $h = $self->_fields_to_hash($fields);
635
636
24270
11333
        my ($base_dur, $dots) = _parse_dur_tokens($h->{Dur} // '4th');
637
24270
10731
        my $rational = Music::NWC2MusicXML::Event->rational_from_nwc_duration($base_dur, $dots);
638
639        $self->_append_event(Music::NWC2MusicXML::Event->new(
640                type     => 'Rest',
641                duration => $rational,
642                data     => {
643                        base_dur => $base_dur,
644                        dots     => $dots,
645
24270
11722
                        opts     => _parse_opts($h->{Opts}),
646                },
647        ));
648}
649
650sub _handle_chord {
651
25770
31556
        my ($self, $fields) = @_;
652
25770
9089
        my $h = $self->_fields_to_hash($fields);
653
654
25770
11873
        my ($base_dur, $dots, $triplet, $artic, $is_grace) = _parse_dur_tokens($h->{Dur} // '4th');
655
25770
11114
        my $rational = Music::NWC2MusicXML::Event->rational_from_nwc_duration($base_dur, $dots);
656
657        # Pos field contains a comma-separated list of position strings.
658        # Each entry: optional accidental prefix (#/b/n/##/bb/x) + signed integer + optional ^ (tie)
659
25770
13299
        my @positions = split /,/, ($h->{Pos} // '0');
660
661        $self->_append_event(Music::NWC2MusicXML::Event->new(
662                type     => 'Chord',
663                duration => $rational,
664                data     => {
665                        nwc_positions => \@positions,
666                        base_dur      => $base_dur,
667                        dots          => $dots,
668                        triplet       => $triplet,
669                        articulations => $artic,
670                        is_grace      => $is_grace,
671
25770
12130
                        opts          => _parse_opts($h->{Opts}),
672                },
673        ));
674}
675
676sub _handle_bar {
677
1049836
1177748
        my ($self, $fields) = @_;
678
1049836
322178
        my $h = $self->_fields_to_hash($fields);
679        # NWC bar records carry an optional Style named field; plain |Bar| has none.
680
1049836
563511
        my $style = $h->{Style} // $h->{_positional} // 'normal';
681
1049836
474204
        $self->_append_event(Music::NWC2MusicXML::Event->new(
682                type => 'Bar',
683                data => { style => $style },
684        ));
685}
686
687# ---------------------------------------------------------------------------
688# Private: duration / options parsing helpers
689# ---------------------------------------------------------------------------
690
691sub _parse_dur_tokens {
692
159697
52706
        my ($dur_str) = @_;
693
159697
62455
        my @tokens   = split /,/, ($dur_str // '4th');
694
159697
57561
        my $base     = shift(@tokens) // '4th';
695
159697
49502
        my ($dots, $triplet, $is_grace) = (0, undef, 0);
696
159697
37139
        my @artic;
697
698
159697
43907
        for my $tok (@tokens) {
699
43727
4262
24027
1801
                if    ($tok eq 'Dotted')                { $dots     = 1 }
700
1
1
                elsif ($tok eq 'DblDotted')             { $dots     = 2 }
701
542
317
                elsif ($tok =~ /\ATriplet(?:=(.+))?\z/) { $triplet  = $1 // 'Middle' }
702
0
0
                elsif ($tok eq 'Grace')                 { $is_grace = 1 }
703
38922
14119
                else                                    { push @artic, $tok }
704        }
705
706
159697
75340
        return ($base, $dots, $triplet, \@artic, $is_grace);
707}
708
709sub _parse_opts {
710
159696
53299
        my ($opts_str) = @_;
711
159696
107890
        return {} unless defined $opts_str && length $opts_str;
712
82235
20138
        my %opts;
713
82235
30016
        for my $opt (split /,/, $opts_str) {
714
159817
134283
77975
62618
                if ($opt =~ /\A(\w+)=(.*)\z/) { $opts{$1} = $2 }
715
25534
8613
                else                        { $opts{$opt} = 1 }
716        }
717
82235
66683
        return \%opts;
718}
719
720sub _handle_dynamic {
721
3002
4557
        my ($self, $fields) = @_;
722
3002
1759
        my $h = $self->_fields_to_hash($fields);
723        $self->_append_event(Music::NWC2MusicXML::Event->new(
724                type => 'Dynamic',
725                data => {
726                        marking   => $h->{Style} // $h->{_positional} // '',
727
3002
5860
                        placement => $h->{Placement} // '',
728                },
729        ));
730}
731
732sub _handle_dyn_variance {
733
5
10
        my ($self, $fields) = @_;
734
5
5
        my $h = $self->_fields_to_hash($fields);
735        $self->_append_event(Music::NWC2MusicXML::Event->new(
736                type => 'DynVariance',
737                data => {
738                        style     => $h->{Style} // $h->{_positional} // '',
739
5
13
                        placement => $h->{Placement} // '',
740                },
741        ));
742}
743
744sub _handle_text {
745
5
9
        my ($self, $fields) = @_;
746
5
4
        my $h = $self->_fields_to_hash($fields);
747
5
4
        my $placement = $h->{Placement};
748
5
7
        if (!defined $placement && defined $h->{Pos}) {
749
3
3
                $placement = ($h->{Pos} >= 0) ? 'Above' : 'Below';
750        }
751        $self->_append_event(Music::NWC2MusicXML::Event->new(
752                type => 'Text',
753
5
9
                data => { text => $h->{Text} // '', placement => $placement // '' },
754        ));
755}
756
757sub _handle_lyric {
758
0
0
        my ($self, $fields) = @_;
759
0
0
        my $h    = $self->_fields_to_hash($fields);
760
0
0
        my $text = $h->{Text} // '';
761        # Syllabic type encoded by leading/trailing hyphens: strip them, infer type.
762
0
0
        my $starts_h = ($text =~ s/\A-//);
763
0
0
        my $ends_h   = ($text =~ s/-\z//);
764
0
0
        my $syllabic = $starts_h && $ends_h ? 'middle'
765                     : $starts_h            ? 'end'
766                     : $ends_h              ? 'begin'
767                     :                        'single';
768        $self->_append_event(Music::NWC2MusicXML::Event->new(
769                type => 'Lyric',
770
0
0
                data => { text => $text, verse => $h->{Verse} // 1, syllabic => $syllabic },
771        ));
772}
773
774sub _handle_tie {
775
0
0
        my ($self, $fields) = @_;
776
0
0
        $self->_append_event(Music::NWC2MusicXML::Event->new(
777                type => 'Tie',
778                data => {},
779        ));
780}
781
782sub _handle_slur {
783
0
0
        my ($self, $fields) = @_;
784
0
0
        my $h = $self->_fields_to_hash($fields);
785
0
0
        $self->_append_event(Music::NWC2MusicXML::Event->new(
786                type => 'Slur',
787                data => { _raw_fields => $h },
788        ));
789}
790
791sub _handle_beam {
792
0
0
        my ($self, $fields) = @_;
793
0
0
        my $h = $self->_fields_to_hash($fields);
794
0
0
        $self->_append_event(Music::NWC2MusicXML::Event->new(
795                type => 'Beam',
796                data => { _raw_fields => $h },
797        ));
798}
799
800sub _handle_tuplet {
801
0
0
        my ($self, $fields) = @_;
802
0
0
        my $h = $self->_fields_to_hash($fields);
803
0
0
        $self->_append_event(Music::NWC2MusicXML::Event->new(
804                type => 'Tuplet',
805                data => { _raw_fields => $h },
806        ));
807}
808
809sub _handle_instrument_change {
810
0
0
        my ($self, $fields) = @_;
811
0
0
        my $h = $self->_fields_to_hash($fields);
812        $self->_append_event(Music::NWC2MusicXML::Event->new(
813                type => 'Instrument',
814
0
0
                data => { name => $h->{Name} // '', patch => $h->{Patch} // 0 },
815        ));
816}
817
818sub _handle_flow_control {
819
0
0
        my ($self, $fields) = @_;
820
0
0
        my $h = $self->_fields_to_hash($fields);
821        $self->_append_event(Music::NWC2MusicXML::Event->new(
822                type => 'FlowControl',
823
0
0
                data => { directive => $h->{_positional} // $h->{Style} // '', extra => $h },
824        ));
825}
826
827sub _handle_tempo_variance {
828
122
358
        my ($self, $fields) = @_;
829
122
143
        my $h = $self->_fields_to_hash($fields);
830
831
122
233
        my $style = $h->{Style} // '';
832
122
162
        my $pos   = $h->{Pos}   // 0;
833
122
302
        $self->_append_event(Music::NWC2MusicXML::Event->new(
834                type => 'TempoVariance',
835                data => {
836                        style     => $style,
837                        placement => ($pos >= 0 ? 'above' : 'below'),
838                },
839        ));
840}
841
842sub _handle_spacer {
843        # Spacer is a graphical-layout hint with no MusicXML equivalent.
844        # Per the preservation principle (musical meaning > graphical appearance)
845        # it is silently discarded; no event, no warning.
846
30
98
        return;
847}
848
849sub _handle_rest_chord {
850
60
132
        my ($self, $fields) = @_;
851
60
55
        my $h = $self->_fields_to_hash($fields);
852
853        # RestChord is a multi-voice construct: one layer rests while another
854        # plays.  Full handling requires voice assignment (Phase 4).
855        # Stored as UnsupportedEvent so the raw field data is preserved for
856        # diagnostics and future implementation.
857
60
82
        $self->_append_event(Music::NWC2MusicXML::Event->new(
858                type      => 'UnsupportedEvent',
859                nwc_label => 'RestChord',
860                data      => { _raw_fields => $h },
861        ));
862}
863
864# ---------------------------------------------------------------------------
865# Private: helpers
866# ---------------------------------------------------------------------------
867
868sub _append_event {
869
1216118
332192
        my ($self, $event) = @_;
870
1216118
402815
        my $staff = $self->_current_staff_or_croak($event->type);
871
1216117
439100
        $staff->add_event($event);
872}
873
874sub _current_staff_or_croak {
875
1220317
348256
        my ($self, $context) = @_;
876
1220317
449298
        my $staff = $self->{_score}->current_staff;
877
1220317
391516
        croak _fmt_msg('error_no_staff', $context) unless defined $staff;
878
1220316
348317
        return $staff;
879}
880
881sub _warn_unknown {
882
5
6
        my ($self, $type) = @_;
883
5
9
        carp _fmt_msg('warn_unknown_record', $type, $self->{_line_no});
884}
885
886sub _fifths_from_signature {
887
807
6052
        my ($sig) = @_;
888
807
1400
        return 0 unless defined $sig && length $sig;
889
803
1070
        return 0 if $sig eq $KEY_SIG_NATURAL;
890
891        # Signature is a comma-separated list of accidentals in circle-of-fifths order.
892        # Each item ending in '#' is a sharp; each item ending in 'b' (after a letter) is a flat.
893
644
740
        my @acc = split /,/, $sig;
894
644
740
        return 0 unless @acc;
895
896
644
1370
        if (index($acc[0], '#') >= 0) {
897
247
449
                return scalar @acc;       # positive = sharps
898        } elsif (substr($acc[0], -1, 1) eq 'b') {
899
367
775
                return -(scalar @acc);    # negative = flats
900        }
901
30
35
        return 0;
902}
903
904sub _fmt_msg {
905
36
27
        my ($key, @args) = @_;
906
36
44
        croak "Unknown message key: $key" unless exists $MESSAGES{$key};
907
36
106
        return sprintf $MESSAGES{$key}, @args;
908}
909
9101;
911