| File: | blib/lib/Music/NWC2MusicXML/Parser.pm |
| Coverage: | 82.1% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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 | ||||||
| 7 | our $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. | |||||
| 22 | Readonly::Scalar my $MAX_RECORDS => 1_000_000; | |||||
| 23 | ||||||
| 24 | # The NWCTXT header line prefix | |||||
| 25 | Readonly::Scalar my $HEADER_PREFIX => '!NoteWorthyComposer('; | |||||
| 26 | ||||||
| 27 | # Dynamic markings recognised by NWC | |||||
| 28 | Readonly::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 | |||||
| 32 | Readonly::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). | |||||
| 43 | Readonly::Scalar my $KEY_SIG_NATURAL => 'C'; | |||||
| 44 | ||||||
| 45 | # Pitch step letters recognised in NWC note records | |||||
| 46 | Readonly::Hash my %VALID_STEPS => map { $_ => 1 } qw(A B C D E F G); | |||||
| 47 | ||||||
| 48 | Readonly::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 | ||||||
| 167 | sub 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 | ||||||
| 264 | sub 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 | # --------------------------------------------------------------------------- | |||||
| 311 | Readonly::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 | ||||||
| 351 | sub _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 | ||||||
| 385 | sub _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. | |||||
| 431 | sub _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 | ||||||
| 448 | sub _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 | ||||||
| 460 | sub _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 | ||||||
| 469 | sub _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 | ||||||
| 476 | sub _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 | ||||||
| 486 | sub _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 | ||||||
| 503 | sub _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 | ||||||
| 527 | sub _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 | ||||||
| 547 | sub _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 | ||||||
| 569 | sub _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 | ||||||
| 597 | sub _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 | ||||||
| 610 | sub _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 | ||||||
| 632 | sub _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 | ||||||
| 650 | sub _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 | ||||||
| 676 | sub _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 | ||||||
| 691 | sub _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 | ||||||
| 709 | sub _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 | ||||||
| 720 | sub _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 | ||||||
| 732 | sub _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 | ||||||
| 744 | sub _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 | ||||||
| 757 | sub _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 | ||||||
| 774 | sub _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 | ||||||
| 782 | sub _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 | ||||||
| 791 | sub _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 | ||||||
| 800 | sub _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 | ||||||
| 809 | sub _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 | ||||||
| 818 | sub _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 | ||||||
| 827 | sub _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 | ||||||
| 842 | sub _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 | ||||||
| 849 | sub _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 | ||||||
| 868 | sub _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 | ||||||
| 874 | sub _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 | ||||||
| 881 | sub _warn_unknown { | |||||
| 882 | 5 | 6 | my ($self, $type) = @_; | |||
| 883 | 5 | 9 | carp _fmt_msg('warn_unknown_record', $type, $self->{_line_no}); | |||
| 884 | } | |||||
| 885 | ||||||
| 886 | sub _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 | ||||||
| 904 | sub _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 | ||||||
| 910 | 1; | |||||
| 911 | ||||||