Completed
Push — master ( 7edb5f...7211b3 )
by Patrick
02:04
created

Note::normalizeAccidental()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 12
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 6
nc 3
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace ExtendedStrings\Strings;
6
7
class Note
8
{
9
    const ACCIDENTAL_NATURAL = '';
10
    const ACCIDENTAL_SHARP = '#';
11
    const ACCIDENTAL_FLAT = 'b';
12
    const ACCIDENTAL_DOUBLE_SHARP = 'x';
13
    const ACCIDENTAL_DOUBLE_FLAT = 'bb';
14
    const ACCIDENTAL_QUARTER_SHARP = '+';
15
    const ACCIDENTAL_QUARTER_FLAT = '-';
16
    const ACCIDENTAL_THREE_QUARTER_SHARP = '#+';
17
    const ACCIDENTAL_THREE_QUARTER_FLAT = 'b-';
18
19
    private static $accidentalPatterns = [
20
        '' => self::ACCIDENTAL_NATURAL,
21
        "([fb]|\u{266D}|flat)" => self::ACCIDENTAL_FLAT,
22
        "([s#]|\u{266F}|sharp)" => self::ACCIDENTAL_SHARP,
23
        '(\-|quarter[ -]flat)' => self::ACCIDENTAL_QUARTER_FLAT,
24
        '(\+|quarter[ -]sharp)' => self::ACCIDENTAL_QUARTER_SHARP,
25
        '(bb|double[ -]flat)' => self::ACCIDENTAL_DOUBLE_FLAT,
26
        '(##|x|double[ -]sharp)' => self::ACCIDENTAL_DOUBLE_SHARP,
27
        '(b\-|(three|3)[ -]quarter[ -]flat)' => self::ACCIDENTAL_THREE_QUARTER_FLAT,
28
        '(#\+|(three|3)[ -]quarter[ -]sharp)' => self::ACCIDENTAL_THREE_QUARTER_SHARP,
29
    ];
30
31
    private static $accidentalCents = [
32
        self::ACCIDENTAL_NATURAL => 0,
33
        self::ACCIDENTAL_FLAT => -100,
34
        self::ACCIDENTAL_SHARP => 100,
35
        self::ACCIDENTAL_QUARTER_FLAT => -50,
36
        self::ACCIDENTAL_QUARTER_SHARP => 50,
37
        self::ACCIDENTAL_DOUBLE_FLAT => -200,
38
        self::ACCIDENTAL_DOUBLE_SHARP => 200,
39
        self::ACCIDENTAL_THREE_QUARTER_FLAT => -150,
40
        self::ACCIDENTAL_THREE_QUARTER_SHARP => 150,
41
    ];
42
43
    private static $preferredAccidentals = [
44
        self::ACCIDENTAL_NATURAL,
45
        self::ACCIDENTAL_SHARP,
46
        self::ACCIDENTAL_FLAT,
47
        self::ACCIDENTAL_QUARTER_SHARP,
48
        self::ACCIDENTAL_QUARTER_FLAT,
49
        self::ACCIDENTAL_DOUBLE_SHARP,
50
        self::ACCIDENTAL_DOUBLE_FLAT,
51
        self::ACCIDENTAL_THREE_QUARTER_FLAT,
52
        self::ACCIDENTAL_THREE_QUARTER_SHARP,
53
    ];
54
55
    private static $names = [
56
        'C' => 0,
57
        'D' => 200,
58
        'E' => 400,
59
        'F' => 500,
60
        'G' => 700,
61
        'A' => 900,
62
        'B' => 1100,
63
    ];
64
65
    private $name;
66
    private $accidental;
67
    private $octave;
68
69
    /**
70
     * Internal constructor: use one of the factory methods to create a Note.
71
     *
72
     * @param string $name       The note name (A-G).
73
     * @param string $accidental The accidental (one of the Note::ACCIDENTAL_
74
     *                           constants).
75
     * @param int    $octave     The octave, in scientific pitch notation.
76
     */
77
    private function __construct(string $name, string $accidental, int $octave)
78
    {
79
        $this->name = $name;
80
        $this->accidental = $accidental;
81
        $this->octave = $octave;
82
    }
83
84
    /**
85
     * Factory to create a Note from a note name.
86
     *
87
     * @param string $name A note name with an accidental and an octave in
88
     *                     scientific pitch notation, e.g. C#4 or Eb5.
89
     *
90
     * @return \ExtendedStrings\Strings\Note
91
     */
92
    public static function fromName(string $name): self
93
    {
94
        $original = $name;
95
        if (!preg_match('/^[a-g]/i', $name, $matches)) {
96
            throw new \InvalidArgumentException(sprintf('Invalid note name: %s', $original));
97
        }
98
        $noteName = strtoupper($matches[0]);
99
        $name = substr($name, strlen($matches[0]));
100
        if (preg_match('/^\-[0-9]$/i', $name)) {
101
            throw new \InvalidArgumentException(sprintf(
102
                'Ambiguous note: %s (does "-" mean a quarter-flat or a negative?)',
103
                $original
104
            ));
105
        }
106
        $octave = 4;
107
        if (preg_match('/\-?[0-9]+$/i', $name, $matches)) {
108
            $octave = intval(ltrim($matches[0]));
109
            $name = substr($name, 0, strlen($name) - strlen($matches[0]));
110
        }
111
        $accidental = self::normalizeAccidental($name);
112
113
        return new self($noteName, $accidental, $octave);
114
    }
115
116
    /**
117
     * Factory to create a Note from a number of cents.
118
     *
119
     * @param int      $cents                A number of cents above C4.
120
     * @param string[] $preferredAccidentals A list of accidentals in order of
121
     *                                       preference. This will be merged
122
     *                                       with a default list.
123
     *
124
     * @return self
125
     */
126
    public static function fromCents(int $cents, array $preferredAccidentals = []): self
127
    {
128
        $octave = (int) floor($cents / 1200) + 4;
129
        $centsWithoutOctave = $cents - (($octave - 4) * 1200);
130
        $preferredAccidentals = array_merge($preferredAccidentals, self::$preferredAccidentals);
131
        foreach ($preferredAccidentals as $accidental) {
132
            $accidentalCents = self::$accidentalCents[$accidental];
133
            if (($name = array_search($centsWithoutOctave - $accidentalCents, self::$names, true)) !== false) {
134
                return new self((string) $name, $accidental, $octave);
135
            }
136
        }
137
138
        throw new \InvalidArgumentException(sprintf('Failed to find note name for cents: %d', $cents));
139
    }
140
141
    /**
142
     * @param string $accidental
143
     *
144
     * @return string
145
     */
146
    private static function normalizeAccidental(string $accidental): string
147
    {
148
        $accidental = trim($accidental);
149
150
        foreach (self::$accidentalPatterns as $pattern => $replacement) {
151
            if (preg_match('/^' . $pattern . '$/i', $accidental)) {
152
                return $replacement;
153
            }
154
        }
155
156
        throw new \InvalidArgumentException(sprintf('Invalid accidental: %s', $accidental));
157
    }
158
159
    /**
160
     * @return int The number of cents above C4.
161
     */
162
    public function getCents(): int
163
    {
164
        return self::$names[$this->name]
165
            + self::$accidentalCents[$this->accidental]
166
            + (($this->octave - 4) * 1200);
167
    }
168
169
    /**
170
     * @return string
171
     */
172
    public function __toString(): string
173
    {
174
        return sprintf('%s%s%d', $this->name, $this->accidental, $this->octave);
175
    }
176
}
177