lib/Music/NWC2MusicXML/Event.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 81.25%
TER3 (LCSAJ): 100.0% (2/2)
Approximate LCSAJ segments: 17

LCSAJ Legend

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.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    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