TER1 (Statement): 100.00%
TER2 (Branch): 81.25%
TER3 (LCSAJ): 100.0% (2/2)
Approximate LCSAJ segments: 17
● Covered — this LCSAJ path was executed during testing.
● Not covered — this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start → end → jump
Uncovered paths show [NOT COVERED] in the tooltip.
1: package Music::NWC2MusicXML::Event; 2: 3: use strict; 4: use warnings; 5: 6: our $VERSION = '0.001.1'; 7: 8: use Carp qw(croak carp); 9: use Readonly; 10: use Params::Validate::Strict qw(validate_strict); 11: use Params::Get; 12: 13: # --------------------------------------------------------------------------- 14: # Valid event types (both musical events and score/staff metadata markers) 15: # --------------------------------------------------------------------------- 16: Readonly::Hash my %MUSICAL_EVENT_TYPES => map { $_ => 1 } qw( 17: Note 18: Rest 19: Chord 20: Clef 21: Key 22: TimeSig 23: Tempo 24: Dynamic 25: DynVariance 26: TempoVariance 27: Text 28: Lyric 29: Bar 30: Tie 31: Slur 32: Beam 33: Tuplet 34: Instrument 35: FlowControl 36: UnsupportedEvent 37: ); 38: 39: Readonly::Hash my %METADATA_EVENT_TYPES => map { $_ => 1 } qw( 40: SongInfo 41: PgSetup 42: AddStaff 43: StaffProperties 44: StaffInstrument 45: ); 46: 47: # --------------------------------------------------------------------------- 48: # Duration map: NWC duration name -> rational denominator (quarter = 1/1) 49: # Using [numerator, denominator] pairs where the base unit is one quarter note. 50: # --------------------------------------------------------------------------- 51: Readonly::Hash my %DURATION_RATIONALS => ( 52: 'Whole' => [ 4, 1 ], 53: 'Half' => [ 2, 1 ], 54: '4th' => [ 1, 1 ], 55: '8th' => [ 1, 2 ], 56: '16th' => [ 1, 4 ], 57: '32nd' => [ 1, 8 ], 58: '64th' => [ 1, 16 ], 59: ); 60: 61: # Dotted note: multiply by 3/2; double-dotted: 7/4 62: Readonly::Scalar my $DOT_NUMERATOR => 3; 63: Readonly::Scalar my $DOT_DENOMINATOR => 2; 64: 65: Readonly::Hash my %MESSAGES => ( 66: error_unknown_type => 'Unknown event type: %s', 67: error_bad_duration => 'Unrecognised NWC duration: %s', 68: error_bad_rational => 'Rational arguments must be positive integers', 69: error_internal => 'Internal error: %s', 70: ); 71: 72: =head1 NAME 73: 74: Music::NWC2MusicXML::Event - Internal representation of a single NWC musical event 75: or score metadata record. 76: 77: =head1 VERSION 78: 79: 0.001.1 80: 81: =head1 SYNOPSIS 82: 83: use Music::NWC2MusicXML::Event; 84: 85: # Musical event 86: my $note = Music::NWC2MusicXML::Event->new( 87: type => 'Note', 88: start_time => [0, 1], # rational: 0 quarter-notes from measure start 89: duration => [1, 1], # rational: one quarter note 90: data => { 91: pitch => 'C', 92: octave => 4, 93: accidental => 0, 94: }, 95: ); 96: 97: # Unsupported / unknown object 98: my $unknown = Music::NWC2MusicXML::Event->new( 99: type => 'UnsupportedEvent', 100: nwc_label => 'SomeFutureObject', 101: data => { raw => '|SomeFutureObject|...' }, 102: ); 103: 104: =head1 DESCRIPTION 105: 106: C<Music::NWC2MusicXML::Event> is the internal representation of one NWC record. 107: It covers both musical events (notes, rests, bars, dynamics, ...) and 108: score/staff metadata (SongInfo, StaffProperties, ...). 109: 110: Musical timing is represented using exact rational numbers stored as 111: two-element arrayrefs C<[$numerator, $denominator]>. Floating-point 112: arithmetic is never used for musical durations; this avoids cumulative 113: rounding errors when dividing a beat into complex tuplet groupings. 114: 115: Unknown NWC object types are stored as C<UnsupportedEvent> records so 116: that conversion can continue rather than aborting. 117: 118: =cut 119: 120: # --------------------------------------------------------------------------- 121: # new 122: # --------------------------------------------------------------------------- 123: 124: =head2 new 125: 126: Construct a new Event. 127: 128: =head3 Purpose 129: 130: Factory constructor used by the parser to wrap each parsed NWC record into a 131: typed, time-stamped object suitable for later MusicXML generation. 132: 133: =head3 Arguments 134: 135: Named parameters: 136: 137: =over 4 138: 139: =item C<type> (string, required) -- one of the recognised event type names 140: listed in C<%MUSICAL_EVENT_TYPES> or C<%METADATA_EVENT_TYPES>, or 141: C<UnsupportedEvent>. 142: 143: =item C<nwc_label> (string, optional) -- the original NWC record label, useful 144: when C<type> is C<UnsupportedEvent>. 145: 146: =item C<start_time> (arrayref [$num,$den], optional) -- rational offset from 147: the beginning of the current measure. Default C<[0,1]>. 148: 149: =item C<duration> (arrayref [$num,$den], optional) -- rational duration in 150: quarter-note units. Default C<[0,1]> for non-durational events. 151: 152: =item C<data> (hashref, optional) -- type-specific payload (see below). 153: 154: =back 155: 156: =head3 Data payloads by type 157: 158: =over 4 159: 160: =item B<Note> -- C<pitch>, C<octave>, C<accidental>, C<tie_start>, C<tie_stop>, 161: C<slur_start>, C<slur_stop>, C<articulations> (arrayref), C<stem_direction>, 162: C<grace>. 163: 164: =item B<Rest> -- (no pitch fields). 165: 166: =item B<Chord> -- C<notes> (arrayref of Note-like hashrefs). 167: 168: =item B<Clef> -- C<nwc_clef> (e.g. C<Treble>). 169: 170: =item B<Key> -- C<nwc_signature> (e.g. C<Bb>), C<tonic>, C<mode>. 171: 172: =item B<TimeSig> -- C<beats>, C<beat_type>. 173: 174: =item B<Tempo> -- C<bpm>. 175: 176: =item B<Dynamic> -- C<marking> (e.g. C<mf>). 177: 178: =item B<Lyric> -- C<text>, C<verse>, C<syllabic> (C<begin>|C<middle>|C<end>|C<single>). 179: 180: =item B<Bar> -- C<style> (C<normal>|C<double>|C<final>|C<repeat_start>|C<repeat_end>|C<section>). 181: 182: =item B<FlowControl> -- C<directive> (e.g. C<Coda>, C<Segno>, C<DaCapo>). 183: 184: =item B<UnsupportedEvent> -- C<raw> (original NWC text line). 185: 186: =back 187: 188: =head3 Returns 189: 190: A blessed C<Music::NWC2MusicXML::Event> object. 191: 192: =head3 Side Effects 193: 194: None. 195: 196: =head3 Usage Example 197: 198: my $rest = Music::NWC2MusicXML::Event->new( 199: type => 'Rest', 200: start_time => [1, 1], 201: duration => [1, 2], # eighth rest 202: ); 203: 204: =head3 API SPECIFICATION 205: 206: =head4 Input 207: 208: type : SCALAR (required) 209: -- Valid domain: any string registered in %MUSICAL_EVENT_TYPES 210: -- or %METADATA_EVENT_TYPES (see Event.pm constants) 211: -- Invalid partition: unknown/unregistered string, undef, or '' 212: -- -> stored as UnsupportedEvent (carp), not croak 213: nwc_label : SCALAR (optional) 214: start_time : ARRAYREF [int>=0, int>0] (optional, default [0,1]) 215: -- Valid domain: exactly-2-element arrayref [numerator, denominator] 216: -- where denominator > 0. Numerator >= 0. 217: -- Invalid: non-arrayref, 1-element, 3+-element, or denominator=0 218: -- -> croak error_bad_rational 219: duration : ARRAYREF [int>=0, int>0] (optional, default [0,1]) 220: -- Same constraints as start_time 221: data : HASHREF (optional, default {}) 222: 223: =head4 Output 224: 225: Music::NWC2MusicXML::Event object 226: 227: =head3 MESSAGES 228: 229: | Code | Meaning | Resolution | 230: |---------------------|-----------------------------------------|------------------------| 231: | error_unknown_type | Type string not in recognised set | Check NWC record label | 232: | error_bad_rational | start_time or duration malformed | Use [$num,$den] form | 233: 234: =head3 FORMAL SPECIFICATION 235: 236: [EventInit] 237: type : EventType 238: start_time : Q+ (non-negative rational) 239: duration : Q+ (non-negative rational) 240: data : DATA 241: 242: (placeholder -- populate with Z calculus as implementation matures) 243: 244: =cut 245: 246: sub new { ●247 → 263 → 273 247: my ($class, %input) = @_; 248: my $args = validate_strict( 249: schema => { 250: type => { type => 'scalar' }, 251: nwc_label => { type => 'scalar', optional => 1 }, 252: start_time => { type => 'arrayref', optional => 1, default => [0, 1] }, 253: duration => { type => 'arrayref', optional => 1, default => [0, 1] }, 254: data => { type => 'hashref', optional => 1, default => {} }, 255: }, 256: input => \%input, 257: ); 258: croak $@ unless defined $args; 259: 260: # Normalise unknown types to UnsupportedEvent rather than croaking; 261: # this preserves conversion continuity when new NWC versions add objects. 262: my $type_key = $args->{type} // ''; 263: unless (Mutants (Total: 1, Killed: 1, Survived: 0)
264: exists $MUSICAL_EVENT_TYPES{$type_key} 265: || exists $METADATA_EVENT_TYPES{$type_key} 266: ) { 267: carp _fmt_msg('error_unknown_type', $type_key || '(undef)') 268: . ' -- storing as UnsupportedEvent'; 269: $args->{nwc_label} //= $args->{type}; 270: $args->{type} = 'UnsupportedEvent'; 271: } 272: 273: _validate_rational($args->{start_time}); 274: _validate_rational($args->{duration}); 275: 276: my $self = bless { 277: _type => $args->{type}, 278: _nwc_label => $args->{nwc_label} // $args->{type}, 279: _start_time => _reduce_rational($args->{start_time}), 280: _duration => _reduce_rational($args->{duration}), 281: _data => $args->{data}, 282: }, $class; 283: 284: return $self;
Mutants (Total: 2, Killed: 2, Survived: 0)
285: } 286: 287: # --------------------------------------------------------------------------- 288: # Accessors 289: # --------------------------------------------------------------------------- 290: 291: =head2 type 292: 293: Return the event type string. 294: 295: =head3 Returns 296: 297: Scalar string. 298: 299: =cut 300: 301: sub type { return $_[0]->{_type} }
Mutants (Total: 2, Killed: 2, Survived: 0)
302: 303: =head2 nwc_label 304: 305: Return the original NWC record label (especially useful for UnsupportedEvent). 306: 307: =cut 308: 309: sub nwc_label { return $_[0]->{_nwc_label} }
Mutants (Total: 2, Killed: 2, Survived: 0)
310: 311: =head2 start_time 312: 313: Return the rational start time as an arrayref C<[$num, $den]>. 314: 315: =cut 316: 317: sub start_time { return $_[0]->{_start_time} }
Mutants (Total: 2, Killed: 2, Survived: 0)
318: 319: =head2 duration 320: 321: Return the rational duration as an arrayref C<[$num, $den]>. 322: 323: =cut 324: 325: sub duration { return $_[0]->{_duration} }
Mutants (Total: 2, Killed: 2, Survived: 0)
326: 327: =head2 data 328: 329: Return the type-specific payload hashref. 330: 331: =cut 332: 333: sub data { return $_[0]->{_data} }
Mutants (Total: 2, Killed: 2, Survived: 0)
334: 335: =head2 is_musical_event 336: 337: Return true if this event contributes to the musical timeline (note, rest, 338: chord, ...) as opposed to being a metadata record. 339: 340: =cut 341: 342: sub is_musical_event { 343: my ($self) = @_; 344: return exists $MUSICAL_EVENT_TYPES{ $self->{_type} };
Mutants (Total: 2, Killed: 2, Survived: 0)
345: } 346: 347: =head2 is_metadata 348: 349: Return true if this record is score/staff metadata. 350: 351: =cut 352: 353: sub is_metadata { 354: my ($self) = @_; 355: return exists $METADATA_EVENT_TYPES{ $self->{_type} };
Mutants (Total: 2, Killed: 2, Survived: 0)
356: } 357: 358: # --------------------------------------------------------------------------- 359: # Rational arithmetic helpers (class-level, not methods) 360: # --------------------------------------------------------------------------- 361: 362: =head2 rational_from_nwc_duration 363: 364: Class method. Convert an NWC duration name and dot count to a rational 365: arrayref C<[$num, $den]> in quarter-note units. 366: 367: =head3 Arguments 368: 369: =over 4 370: 371: =item C<nwc_duration> -- string such as C<4th>, C<8th>, C<Half> etc. 372: 373: =item C<dots> -- number of augmentation dots (0, 1, or 2). 374: 375: =back 376: 377: =head3 Returns 378: 379: Arrayref C<[$num, $den]>. 380: 381: =head3 API SPECIFICATION 382: 383: =head4 Input 384: 385: nwc_duration : SCALAR (required) 386: -- Valid domain: exactly one of the 7 NWC duration names: 387: -- 'Whole', 'Half', '4th', '8th', '16th', '32nd', '64th' 388: -- Invalid partitions: undef, '' (empty), 'Quarter' (wrong name), 389: -- 'whole' (wrong case), any other string -> croak error_bad_duration 390: -- Note: NWC uses '4th' not 'Quarter' for the quarter note. 391: dots : SCALAR int >= 0 (optional, default 0) 392: -- Valid domain: 0 (no dots), 1 (dotted), 2 (double-dotted) 393: -- NWC maximum is 2; dots=3+ are mathematically valid but not 394: -- produced by any NWC 2.x score; undef treated as 0 395: 396: =head4 Output 397: 398: ARRAYREF [$num:int, $den:int] (rational in quarter-note units, reduced to lowest terms) 399: 400: =head3 FORMAL SPECIFICATION 401: 402: (placeholder) 403: 404: =cut 405: 406: sub rational_from_nwc_duration { ●407 → 420 → 426 407: my $class = shift; 408: my ($dur, $dots) = @_; 409: $dots //= 0; 410: 411: croak _fmt_msg('error_bad_duration', $dur // '(undef)') 412: unless defined $dur && exists $DURATION_RATIONALS{$dur}; 413: 414: my $r = $DURATION_RATIONALS{$dur}; 415: # P: table values are already in lowest terms; _reduce_rational is a no-op for dots=0. 416: return $r unless $dots;
Mutants (Total: 2, Killed: 2, Survived: 0)
417: 418: my $base_r = $r; # preserve original: dot d adds base/2^d, not cur/2^d 419: 420: for my $d (1 .. $dots) { 421: my $add_num = $base_r->[0]; 422: my $add_den = $base_r->[1] * (2 ** $d); 423: $r = _add_rationals($r, [$add_num, $add_den]); 424: } 425: 426: return _reduce_rational($r);
Mutants (Total: 2, Killed: 2, Survived: 0)
427: } 428: 429: =head2 rational_add 430: 431: Class method. Add two rational numbers. 432: 433: =head3 Arguments 434: 435: Two arrayrefs C<[$n1,$d1]> and C<[$n2,$d2]>. 436: 437: =head3 Returns 438: 439: Arrayref C<[$num,$den]> in lowest terms. 440: 441: =cut 442: 443: sub rational_add { 444: my ($class, $r1, $r2) = @_; 445: return _reduce_rational(_add_rationals($r1, $r2));
Mutants (Total: 2, Killed: 2, Survived: 0)
446: } 447: 448: =head2 rational_to_float 449: 450: Class method. Convert a rational to a floating-point number for display 451: or approximate comparison only. Never use the result for musical timing. 452: 453: =head3 API SPECIFICATION 454: 455: =head4 Input 456: 457: $r : ARRAYREF [$num:int, $den:int>0] (required) 458: -- Valid domain: exactly-2-element arrayref where $den > 0 459: -- Invalid: non-arrayref scalar, 1-element arrayref, 3+-element arrayref, 460: -- or denominator = 0 -> croak error_bad_rational 461: -- BVA boundary: $num = 0 is valid (returns 0.0) 462: 463: =head4 Output 464: 465: SCALAR (floating-point approximation of $num / $den) 466: 467: =cut 468: 469: sub rational_to_float { 470: my ($class, $r) = @_; 471: croak _fmt_msg('error_bad_rational') unless ref $r eq 'ARRAY' && @$r == 2 && $r->[1];
Mutants (Total: 1, Killed: 1, Survived: 0)
472: return $r->[0] / $r->[1];
Mutants (Total: 2, Killed: 2, Survived: 0)
473: } 474: 475: # --------------------------------------------------------------------------- 476: # Private helpers 477: # --------------------------------------------------------------------------- 478: 479: sub _validate_rational { 480: my ($r) = @_; 481: croak _fmt_msg('error_bad_rational') 482: unless ref $r eq 'ARRAY' 483: && @$r == 2
Mutants (Total: 1, Killed: 1, Survived: 0)
484: && $r->[0] =~ /\A\d+\z/ 485: && $r->[1] =~ /\A[1-9]\d*\z/; 486: return; 487: } 488: 489: sub _add_rationals { 490: my ($r1, $r2) = @_; 491: my $num = $r1->[0] * $r2->[1] + $r2->[0] * $r1->[1]; 492: my $den = $r1->[1] * $r2->[1]; 493: return [$num, $den]; 494: } 495: 496: sub _reduce_rational { 497: my ($r) = @_; 498: my $g = _gcd($r->[0], $r->[1]); 499: return $g ? [$r->[0] / $g, $r->[1] / $g] : [0, 1];
Mutants (Total: 2, Killed: 2, Survived: 0)
500: } 501: 502: sub _gcd { 503: my ($a, $b) = @_; 504: ($a, $b) = ($b, $a % $b) while $b; 505: return $a;
Mutants (Total: 2, Killed: 2, Survived: 0)
506: } 507: 508: sub _fmt_msg { 509: my ($key, @args) = @_; 510: croak "Unknown message key: $key" unless exists $MESSAGES{$key}; 511: return sprintf $MESSAGES{$key}, @args;
Mutants (Total: 2, Killed: 2, Survived: 0)
512: } 513: 514: 1; 515: 516: __END__ 517: 518: =head1 DIAGNOSTICS 519: 520: =head3 MESSAGES 521: 522: | Code | Meaning | Resolution | 523: |----------------------|--------------------------------------|-----------------------------| 524: | error_unknown_type | Unrecognised event type | Will be stored as UnsupportedEvent | 525: | error_bad_duration | NWC duration name not in lookup table| Check parser input | 526: | error_bad_rational | Rational arrayref malformed | Use [$non-neg-int, $pos-int] | 527: 528: =head1 LIMITATIONS 529: 530: =over 4 531: 532: =item * Double-dotted notes supported; triple-dotted are not. 533: 534: =item * Tuplet time-modification is not computed here; the parser applies it to each affected event. 535: 536: =back 537: 538: =head1 AUTHOR 539: 540: Nigel Horne C<< <nigel.horne@gmail.com> >> 541: 542: =head1 LICENSE 543: 544: This library is free software; you can redistribute it and/or modify it 545: under the same terms as Perl itself. 546: 547: =cut