Completed
Push — master ( 01328d...a2e8b8 )
by Patrick
02:00
created

Note::getFrequency()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
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
    private $difference;
69
70
    /**
71
     * Internal constructor: use one of the factory methods to create a Note.
72
     *
73
     * @param string $name         The note name (A-G).
74
     * @param string $accidental   The accidental (one of the Note::ACCIDENTAL_
75
     *                             constants).
76
     * @param int    $octave       The octave, in scientific pitch notation.
77
     * @param float  $difference   The note's difference in cents from 12-TET.
78
     */
79
    private function __construct(string $name, string $accidental, int $octave, float $difference)
80
    {
81
        $this->name = $name;
82
        $this->accidental = $accidental;
83
        $this->octave = $octave;
84
        $this->difference = $difference;
85
    }
86
87
    /**
88
     * Factory to create a Note from a note name.
89
     *
90
     * @param string $name A note name with an accidental and an octave in
91
     *                     scientific pitch notation, e.g. C#4 or Eb5.
92
     *
93
     * @return \ExtendedStrings\Strings\Note
94
     */
95
    public static function fromName(string $name): self
96
    {
97
        $original = $name;
98
        if (!preg_match('/^[a-g]/i', $name, $matches)) {
99
            throw new \InvalidArgumentException(sprintf('Invalid note name: %s', $original));
100
        }
101
        $noteName = strtoupper($matches[0]);
102
        $name = substr($name, strlen($matches[0]));
103
        if (preg_match('/^\-[0-9]+$/i', $name)) {
104
            throw new \InvalidArgumentException(sprintf(
105
                'Ambiguous note: %s (does "-" mean a quarter-flat or a negative?)',
106
                $original
107
            ));
108
        }
109
        $octave = 4;
110
        $difference = 0;
111
        if (preg_match('/(\-?[0-9]+)?( ([\+-][0-9]+)c)?$/i', $name, $matches)) {
112
            if (isset($matches[1])) {
113
                $octave = intval($matches[1]);
114
            }
115
            if (isset($matches[3])) {
116
                $difference = intval($matches[3]);
117
            }
118
            $name = substr($name, 0, strlen($name) - strlen($matches[0]));
119
        }
120
        $accidental = self::normalizeAccidental($name);
121
122
        return new self($noteName, $accidental, $octave, $difference);
123
    }
124
125
    /**
126
     * Factory to create a Note from a number of cents.
127
     *
128
     * @param float      $cents              A number of cents above C4.
129
     * @param string[] $preferredAccidentals A list of accidentals in order of
130
     *                                       preference. This will be merged
131
     *                                       with a default list.
132
     *
133
     * @return self
134
     */
135
    public static function fromCents(float $cents, array $preferredAccidentals = []): self
136
    {
137
        $rounded = (int) round($cents / 50) * 50;
138
        $difference = $cents - $rounded;
139
        $octave = (int) floor($rounded / 1200) + 4;
140
        $centsWithoutOctave = $rounded - (($octave - 4) * 1200);
141
        $preferredAccidentals = array_merge($preferredAccidentals, self::$preferredAccidentals);
142
        foreach ($preferredAccidentals as $accidental) {
143
            $accidentalCents = self::$accidentalCents[$accidental];
144
            if (($name = array_search($centsWithoutOctave - $accidentalCents, self::$names, true)) !== false) {
145
                return new self((string) $name, $accidental, $octave, $difference);
146
            }
147
        }
148
149
        throw new \InvalidArgumentException(sprintf('Failed to find note name for cents: %d', $cents));
150
    }
151
152
    /**
153
     * @param string $accidental
154
     *
155
     * @return string
156
     */
157
    private static function normalizeAccidental(string $accidental): string
158
    {
159
        $accidental = trim($accidental);
160
161
        foreach (self::$accidentalPatterns as $pattern => $replacement) {
162
            if (preg_match('/^' . $pattern . '$/i', $accidental)) {
163
                return $replacement;
164
            }
165
        }
166
167
        throw new \InvalidArgumentException(sprintf('Invalid accidental: %s', $accidental));
168
    }
169
170
    /**
171
     * @return float The number of cents above C4.
172
     */
173
    public function getCents(): float
174
    {
175
        return self::$names[$this->name]
176
            + self::$accidentalCents[$this->accidental]
177
            + (($this->octave - 4) * 1200)
178
            + $this->difference;
179
    }
180
181
    /**
182
     * @param float $A4 The frequency of A4, for reference.
183
     *
184
     * @return float The frequency of the note.
185
     */
186
    public function getFrequency(float $A4 = 440.0): float
187
    {
188
        return Cent::centsToFrequency($this->getCents() - 900, $A4);
189
    }
190
191
    /**
192
     * @param float $frequency            The frequency.
193
     * @param float $A4                   The frequency of A4, for reference.
194
     * @param array $preferredAccidentals Some preferred accidentals.
195
     *
196
     * @return self
197
     */
198
    public static function fromFrequency($frequency, float $A4 = 440.0, array $preferredAccidentals = []): self
199
    {
200
        $cents = Cent::frequenciesToCents($A4, $frequency) + 900;
201
202
        return self::fromCents($cents, $preferredAccidentals);
203
    }
204
205
    /**
206
     * @return string
207
     */
208
    public function __toString(): string
209
    {
210
        $output = sprintf('%s%s%d', $this->name, $this->accidental, $this->octave);
211
        if (intval($this->difference) !== 0) {
212
            $output .= sprintf(' %dc', $this->difference);
213
        }
214
215
        return $output;
216
    }
217
}
218