File Coverage

File:blib/lib/Music/NWC2MusicXML/Event.pm
Coverage:97.2%

linestmtbrancondsubtimecode
1package Music::NWC2MusicXML::Event;
2
3
16
16
16
117206
6
65
use strict;
4
16
16
16
11
5
196
use warnings;
5
6our $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# ---------------------------------------------------------------------------
16Readonly::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
39Readonly::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# ---------------------------------------------------------------------------
51Readonly::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
62Readonly::Scalar my $DOT_NUMERATOR   => 3;
63Readonly::Scalar my $DOT_DENOMINATOR => 2;
64
65Readonly::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
246sub 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
342sub 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
353sub 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
406sub 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
443sub 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
469sub 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
479sub _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
489sub _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
496sub _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
502sub _gcd {
503
2555644
879846
        my ($a, $b) = @_;
504
2555644
1594692
        ($a, $b) = ($b, $a % $b) while $b;
505
2555644
863813
        return $a;
506}
507
508sub _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
5141;
515