| File: | blib/lib/Music/NWC2MusicXML/Event.pm |
| Coverage: | 97.2% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Music::NWC2MusicXML::Event; | |||||
| 2 | ||||||
| 3 | 16 16 16 | 117206 6 65 | use strict; | |||
| 4 | 16 16 16 | 11 5 196 | use warnings; | |||
| 5 | ||||||
| 6 | our $VERSION = '0.001.1'; | |||||
| 7 | ||||||
| 8 | 16 16 16 | 13 6 125 | use Carp qw(croak carp); | |||
| 9 | 16 16 16 | 14 5 126 | use Readonly; | |||
| 10 | 16 16 16 | 182 10938 126 | use Params::Validate::Strict qw(validate_strict); | |||
| 11 | 16 16 16 | 128 8806 5374 | 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 - 118 | =head1 NAME
Music::NWC2MusicXML::Event - Internal representation of a single NWC musical event
or score metadata record.
=head1 VERSION
0.001.1
=head1 SYNOPSIS
use Music::NWC2MusicXML::Event;
# Musical event
my $note = Music::NWC2MusicXML::Event->new(
type => 'Note',
start_time => [0, 1], # rational: 0 quarter-notes from measure start
duration => [1, 1], # rational: one quarter note
data => {
pitch => 'C',
octave => 4,
accidental => 0,
},
);
# Unsupported / unknown object
my $unknown = Music::NWC2MusicXML::Event->new(
type => 'UnsupportedEvent',
nwc_label => 'SomeFutureObject',
data => { raw => '|SomeFutureObject|...' },
);
=head1 DESCRIPTION
C<Music::NWC2MusicXML::Event> is the internal representation of one NWC record.
It covers both musical events (notes, rests, bars, dynamics, ...) and
score/staff metadata (SongInfo, StaffProperties, ...).
Musical timing is represented using exact rational numbers stored as
two-element arrayrefs C<[$numerator, $denominator]>. Floating-point
arithmetic is never used for musical durations; this avoids cumulative
rounding errors when dividing a beat into complex tuplet groupings.
Unknown NWC object types are stored as C<UnsupportedEvent> records so
that conversion can continue rather than aborting.
=cut | |||||
| 119 | ||||||
| 120 | # --------------------------------------------------------------------------- | |||||
| 121 | # new | |||||
| 122 | # --------------------------------------------------------------------------- | |||||
| 123 | ||||||
| 124 - 244 | =head2 new
Construct a new Event.
=head3 Purpose
Factory constructor used by the parser to wrap each parsed NWC record into a
typed, time-stamped object suitable for later MusicXML generation.
=head3 Arguments
Named parameters:
=over 4
=item C<type> (string, required) -- one of the recognised event type names
listed in C<%MUSICAL_EVENT_TYPES> or C<%METADATA_EVENT_TYPES>, or
C<UnsupportedEvent>.
=item C<nwc_label> (string, optional) -- the original NWC record label, useful
when C<type> is C<UnsupportedEvent>.
=item C<start_time> (arrayref [$num,$den], optional) -- rational offset from
the beginning of the current measure. Default C<[0,1]>.
=item C<duration> (arrayref [$num,$den], optional) -- rational duration in
quarter-note units. Default C<[0,1]> for non-durational events.
=item C<data> (hashref, optional) -- type-specific payload (see below).
=back
=head3 Data payloads by type
=over 4
=item B<Note> -- C<pitch>, C<octave>, C<accidental>, C<tie_start>, C<tie_stop>,
C<slur_start>, C<slur_stop>, C<articulations> (arrayref), C<stem_direction>,
C<grace>.
=item B<Rest> -- (no pitch fields).
=item B<Chord> -- C<notes> (arrayref of Note-like hashrefs).
=item B<Clef> -- C<nwc_clef> (e.g. C<Treble>).
=item B<Key> -- C<nwc_signature> (e.g. C<Bb>), C<tonic>, C<mode>.
=item B<TimeSig> -- C<beats>, C<beat_type>.
=item B<Tempo> -- C<bpm>.
=item B<Dynamic> -- C<marking> (e.g. C<mf>).
=item B<Lyric> -- C<text>, C<verse>, C<syllabic> (C<begin>|C<middle>|C<end>|C<single>).
=item B<Bar> -- C<style> (C<normal>|C<double>|C<final>|C<repeat_start>|C<repeat_end>|C<section>).
=item B<FlowControl> -- C<directive> (e.g. C<Coda>, C<Segno>, C<DaCapo>).
=item B<UnsupportedEvent> -- C<raw> (original NWC text line).
=back
=head3 Returns
A blessed C<Music::NWC2MusicXML::Event> object.
=head3 Side Effects
None.
=head3 Usage Example
my $rest = Music::NWC2MusicXML::Event->new(
type => 'Rest',
start_time => [1, 1],
duration => [1, 2], # eighth rest
);
=head3 API SPECIFICATION
=head4 Input
type : SCALAR (required)
-- Valid domain: any string registered in %MUSICAL_EVENT_TYPES
-- or %METADATA_EVENT_TYPES (see Event.pm constants)
-- Invalid partition: unknown/unregistered string, undef, or ''
-- -> stored as UnsupportedEvent (carp), not croak
nwc_label : SCALAR (optional)
start_time : ARRAYREF [int>=0, int>0] (optional, default [0,1])
-- Valid domain: exactly-2-element arrayref [numerator, denominator]
-- where denominator > 0. Numerator >= 0.
-- Invalid: non-arrayref, 1-element, 3+-element, or denominator=0
-- -> croak error_bad_rational
duration : ARRAYREF [int>=0, int>0] (optional, default [0,1])
-- Same constraints as start_time
data : HASHREF (optional, default {})
=head4 Output
Music::NWC2MusicXML::Event object
=head3 MESSAGES
| Code | Meaning | Resolution |
|---------------------|-----------------------------------------|------------------------|
| error_unknown_type | Type string not in recognised set | Check NWC record label |
| error_bad_rational | start_time or duration malformed | Use [$num,$den] form |
=head3 FORMAL SPECIFICATION
[EventInit]
type : EventType
start_time : Q+ (non-negative rational)
duration : Q+ (non-negative rational)
data : DATA
(placeholder -- populate with Z calculus as implementation matures)
=cut | |||||
| 245 | ||||||
| 246 | sub new { | |||||
| 247 | 1275678 | 655827 | my ($class, %input) = @_; | |||
| 248 | 1275678 | 1957747 | 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 | 1275674 | 90380585 | 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 | 1275674 | 496188 | my $type_key = $args->{type} // ''; | |||
| 263 | 1275674 | 713076 | unless ( | |||
| 264 | exists $MUSICAL_EVENT_TYPES{$type_key} | |||||
| 265 | || exists $METADATA_EVENT_TYPES{$type_key} | |||||
| 266 | ) { | |||||
| 267 | 13 | 71 | carp _fmt_msg('error_unknown_type', $type_key || '(undef)') | |||
| 268 | . ' -- storing as UnsupportedEvent'; | |||||
| 269 | 13 | 1693 | $args->{nwc_label} //= $args->{type}; | |||
| 270 | 13 | 7 | $args->{type} = 'UnsupportedEvent'; | |||
| 271 | } | |||||
| 272 | ||||||
| 273 | 1275674 | 1982691 | _validate_rational($args->{start_time}); | |||
| 274 | 1275672 | 497347 | _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 | 1275666 | 770229 | }, $class; | |||
| 283 | ||||||
| 284 | 1275666 | 1354862 | return $self; | |||
| 285 | } | |||||
| 286 | ||||||
| 287 | # --------------------------------------------------------------------------- | |||||
| 288 | # Accessors | |||||
| 289 | # --------------------------------------------------------------------------- | |||||
| 290 | ||||||
| 291 - 299 | =head2 type Return the event type string. =head3 Returns Scalar string. =cut | |||||
| 300 | ||||||
| 301 | 2191034 | 1069493 | sub type { return $_[0]->{_type} } | |||
| 302 | ||||||
| 303 - 307 | =head2 nwc_label Return the original NWC record label (especially useful for UnsupportedEvent). =cut | |||||
| 308 | ||||||
| 309 | 16 | 500 | sub nwc_label { return $_[0]->{_nwc_label} } | |||
| 310 | ||||||
| 311 - 315 | =head2 start_time Return the rational start time as an arrayref C<[$num, $den]>. =cut | |||||
| 316 | ||||||
| 317 | 2 | 6 | sub start_time { return $_[0]->{_start_time} } | |||
| 318 | ||||||
| 319 - 323 | =head2 duration Return the rational duration as an arrayref C<[$num, $den]>. =cut | |||||
| 324 | ||||||
| 325 | 448352 | 332622 | sub duration { return $_[0]->{_duration} } | |||
| 326 | ||||||
| 327 - 331 | =head2 data Return the type-specific payload hashref. =cut | |||||
| 332 | ||||||
| 333 | 730027 | 509792 | sub data { return $_[0]->{_data} } | |||
| 334 | ||||||
| 335 - 340 | =head2 is_musical_event Return true if this event contributes to the musical timeline (note, rest, chord, ...) as opposed to being a metadata record. =cut | |||||
| 341 | ||||||
| 342 | sub is_musical_event { | |||||
| 343 | 14 | 185 | my ($self) = @_; | |||
| 344 | 14 | 10 | return exists $MUSICAL_EVENT_TYPES{ $self->{_type} }; | |||
| 345 | } | |||||
| 346 | ||||||
| 347 - 351 | =head2 is_metadata Return true if this record is score/staff metadata. =cut | |||||
| 352 | ||||||
| 353 | sub is_metadata { | |||||
| 354 | 4 | 180 | my ($self) = @_; | |||
| 355 | 4 | 4 | return exists $METADATA_EVENT_TYPES{ $self->{_type} }; | |||
| 356 | } | |||||
| 357 | ||||||
| 358 | # --------------------------------------------------------------------------- | |||||
| 359 | # Rational arithmetic helpers (class-level, not methods) | |||||
| 360 | # --------------------------------------------------------------------------- | |||||
| 361 | ||||||
| 362 - 404 | =head2 rational_from_nwc_duration Class method. Convert an NWC duration name and dot count to a rational arrayref C<[$num, $den]> in quarter-note units. =head3 Arguments =over 4 =item C<nwc_duration> -- string such as C<4th>, C<8th>, C<Half> etc. =item C<dots> -- number of augmentation dots (0, 1, or 2). =back =head3 Returns Arrayref C<[$num, $den]>. =head3 API SPECIFICATION =head4 Input nwc_duration : SCALAR (required) -- Valid domain: exactly one of the 7 NWC duration names: -- 'Whole', 'Half', '4th', '8th', '16th', '32nd', '64th' -- Invalid partitions: undef, '' (empty), 'Quarter' (wrong name), -- 'whole' (wrong case), any other string -> croak error_bad_duration -- Note: NWC uses '4th' not 'Quarter' for the quarter note. dots : SCALAR int >= 0 (optional, default 0) -- Valid domain: 0 (no dots), 1 (dotted), 2 (double-dotted) -- NWC maximum is 2; dots=3+ are mathematically valid but not -- produced by any NWC 2.x score; undef treated as 0 =head4 Output ARRAYREF [$num:int, $den:int] (rational in quarter-note units, reduced to lowest terms) =head3 FORMAL SPECIFICATION (placeholder) =cut | |||||
| 405 | ||||||
| 406 | sub rational_from_nwc_duration { | |||||
| 407 | 159754 | 80867 | my $class = shift; | |||
| 408 | 159754 | 56151 | my ($dur, $dots) = @_; | |||
| 409 | 159754 | 57834 | $dots //= 0; | |||
| 410 | ||||||
| 411 | croak _fmt_msg('error_bad_duration', $dur // '(undef)') | |||||
| 412 | 159754 | 98754 | unless defined $dur && exists $DURATION_RATIONALS{$dur}; | |||
| 413 | ||||||
| 414 | 159741 | 291478 | my $r = $DURATION_RATIONALS{$dur}; | |||
| 415 | # P: table values are already in lowest terms; _reduce_rational is a no-op for dots=0. | |||||
| 416 | 159741 | 254021 | return $r unless $dots; | |||
| 417 | ||||||
| 418 | 4289 | 1499 | my $base_r = $r; # preserve original: dot d adds base/2^d, not cur/2^d | |||
| 419 | ||||||
| 420 | 4289 | 2438 | for my $d (1 .. $dots) { | |||
| 421 | 4300 | 2156 | my $add_num = $base_r->[0]; | |||
| 422 | 4300 | 6522 | my $add_den = $base_r->[1] * (2 ** $d); | |||
| 423 | 4300 | 7421 | $r = _add_rationals($r, [$add_num, $add_den]); | |||
| 424 | } | |||||
| 425 | ||||||
| 426 | 4289 | 1659 | return _reduce_rational($r); | |||
| 427 | } | |||||
| 428 | ||||||
| 429 - 441 | =head2 rational_add Class method. Add two rational numbers. =head3 Arguments Two arrayrefs C<[$n1,$d1]> and C<[$n2,$d2]>. =head3 Returns Arrayref C<[$num,$den]> in lowest terms. =cut | |||||
| 442 | ||||||
| 443 | sub rational_add { | |||||
| 444 | 12 | 7390 | my ($class, $r1, $r2) = @_; | |||
| 445 | 12 | 13 | return _reduce_rational(_add_rationals($r1, $r2)); | |||
| 446 | } | |||||
| 447 | ||||||
| 448 - 467 | =head2 rational_to_float Class method. Convert a rational to a floating-point number for display or approximate comparison only. Never use the result for musical timing. =head3 API SPECIFICATION =head4 Input $r : ARRAYREF [$num:int, $den:int>0] (required) -- Valid domain: exactly-2-element arrayref where $den > 0 -- Invalid: non-arrayref scalar, 1-element arrayref, 3+-element arrayref, -- or denominator = 0 -> croak error_bad_rational -- BVA boundary: $num = 0 is valid (returns 0.0) =head4 Output SCALAR (floating-point approximation of $num / $den) =cut | |||||
| 468 | ||||||
| 469 | sub rational_to_float { | |||||
| 470 | 26 | 17112 | my ($class, $r) = @_; | |||
| 471 | 26 | 69 | croak _fmt_msg('error_bad_rational') unless ref $r eq 'ARRAY' && @$r == 2 && $r->[1]; | |||
| 472 | 16 | 29 | return $r->[0] / $r->[1]; | |||
| 473 | } | |||||
| 474 | ||||||
| 475 | # --------------------------------------------------------------------------- | |||||
| 476 | # Private helpers | |||||
| 477 | # --------------------------------------------------------------------------- | |||||
| 478 | ||||||
| 479 | sub _validate_rational { | |||||
| 480 | 2551353 | 742915 | my ($r) = @_; | |||
| 481 | 2551353 | 3278223 | croak _fmt_msg('error_bad_rational') | |||
| 482 | unless ref $r eq 'ARRAY' | |||||
| 483 | && @$r == 2 | |||||
| 484 | && $r->[0] =~ /\A\d+\z/ | |||||
| 485 | && $r->[1] =~ /\A[1-9]\d*\z/; | |||||
| 486 | 2551341 | 1308473 | return; | |||
| 487 | } | |||||
| 488 | ||||||
| 489 | sub _add_rationals { | |||||
| 490 | 4315 | 2899 | my ($r1, $r2) = @_; | |||
| 491 | 4315 | 1868 | my $num = $r1->[0] * $r2->[1] + $r2->[0] * $r1->[1]; | |||
| 492 | 4315 | 11091 | my $den = $r1->[1] * $r2->[1]; | |||
| 493 | 4315 | 7388 | return [$num, $den]; | |||
| 494 | } | |||||
| 495 | ||||||
| 496 | sub _reduce_rational { | |||||
| 497 | 2555638 | 729657 | my ($r) = @_; | |||
| 498 | 2555638 | 819192 | my $g = _gcd($r->[0], $r->[1]); | |||
| 499 | 2555638 | 1854057 | return $g ? [$r->[0] / $g, $r->[1] / $g] : [0, 1]; | |||
| 500 | } | |||||
| 501 | ||||||
| 502 | sub _gcd { | |||||
| 503 | 2555644 | 879846 | my ($a, $b) = @_; | |||
| 504 | 2555644 | 1594692 | ($a, $b) = ($b, $a % $b) while $b; | |||
| 505 | 2555644 | 863813 | return $a; | |||
| 506 | } | |||||
| 507 | ||||||
| 508 | sub _fmt_msg { | |||||
| 509 | 48 | 58 | my ($key, @args) = @_; | |||
| 510 | 48 | 48 | croak "Unknown message key: $key" unless exists $MESSAGES{$key}; | |||
| 511 | 48 | 135 | return sprintf $MESSAGES{$key}, @args; | |||
| 512 | } | |||||
| 513 | ||||||
| 514 | 1; | |||||
| 515 | ||||||