Note::fromFrequency()   A
last analyzed

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 3
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 = '♭';
12
    const ACCIDENTAL_DOUBLE_SHARP = 'x';
13
    const ACCIDENTAL_DOUBLE_FLAT = '♭♭';
14
    const ACCIDENTAL_QUARTER_SHARP = '¼♯';
15
    const ACCIDENTAL_QUARTER_FLAT = '¼♭';
16
    const ACCIDENTAL_THREE_QUARTER_SHARP = '¾♯';
17
    const ACCIDENTAL_THREE_QUARTER_FLAT = '¾♭';
18
19
    const PATTERN_ACCIDENTAL_SHARP = '([♯s#]|sharp)';
20
    const PATTERN_ACCIDENTAL_FLAT = '([♭fb]|flat)';
21
    const PATTERN_ACCIDENTAL_QUARTER = '(quarter|¼|1\/4)[ -]?';
22
    const PATTERN_ACCIDENTAL_3_QUARTER = '((three|3)[ -]quarter|¾|3\/4)[ -]?';
23
24
    private static $accidentalPatterns = [
25
        '' => self::ACCIDENTAL_NATURAL,
26
        self::PATTERN_ACCIDENTAL_FLAT => self::ACCIDENTAL_FLAT,
27
        self::PATTERN_ACCIDENTAL_SHARP => self::ACCIDENTAL_SHARP,
28
        '(\-|' . self::PATTERN_ACCIDENTAL_QUARTER . self::PATTERN_ACCIDENTAL_FLAT . ')' => self::ACCIDENTAL_QUARTER_FLAT,
29
        '(\+|' . self::PATTERN_ACCIDENTAL_QUARTER . self::PATTERN_ACCIDENTAL_SHARP . ')' => self::ACCIDENTAL_QUARTER_SHARP,
30
        '(𝄫|bb|double[ -]' . self::PATTERN_ACCIDENTAL_FLAT . ')' => self::ACCIDENTAL_DOUBLE_FLAT,
31
        '(𝄪|♯♯|##|double[ -]' . self::PATTERN_ACCIDENTAL_SHARP . ')' => self::ACCIDENTAL_DOUBLE_SHARP,
32
        '(' . self::PATTERN_ACCIDENTAL_FLAT . '\-|' . self::PATTERN_ACCIDENTAL_3_QUARTER . self::PATTERN_ACCIDENTAL_FLAT . ')' => self::ACCIDENTAL_THREE_QUARTER_FLAT,
33
        '(' . self::PATTERN_ACCIDENTAL_SHARP . '\+|' . self::PATTERN_ACCIDENTAL_3_QUARTER . self::PATTERN_ACCIDENTAL_SHARP . ')' => self::ACCIDENTAL_THREE_QUARTER_SHARP,
34
    ];
35
36
    private static $accidentalCents = [
37
        self::ACCIDENTAL_NATURAL => 0,
38
        self::ACCIDENTAL_FLAT => -100,
39
        self::ACCIDENTAL_SHARP => 100,
40
        self::ACCIDENTAL_QUARTER_FLAT => -50,
41
        self::ACCIDENTAL_QUARTER_SHARP => 50,
42
        self::ACCIDENTAL_DOUBLE_FLAT => -200,
43
        self::ACCIDENTAL_DOUBLE_SHARP => 200,
44
        self::ACCIDENTAL_THREE_QUARTER_FLAT => -150,
45
        self::ACCIDENTAL_THREE_QUARTER_SHARP => 150,
46
    ];
47
48
    private static $preferredAccidentals = [
49
        self::ACCIDENTAL_NATURAL,
50
        self::ACCIDENTAL_SHARP,
51
        self::ACCIDENTAL_FLAT,
52
        self::ACCIDENTAL_QUARTER_SHARP,
53
        self::ACCIDENTAL_QUARTER_FLAT,
54
        self::ACCIDENTAL_DOUBLE_SHARP,
55
        self::ACCIDENTAL_DOUBLE_FLAT,
56
        self::ACCIDENTAL_THREE_QUARTER_FLAT,
57
        self::ACCIDENTAL_THREE_QUARTER_SHARP,
58
    ];
59
60
    private static $names = [
61
        'C' => 0,
62
        'D' => 200,
63
        'E' => 400,
64
        'F' => 500,
65
        'G' => 700,
66
        'A' => 900,
67
        'B' => 1100,
68
    ];
69
70
    private $name;
71
    private $accidental;
72
    private $octave;
73
    private $difference;
74
75
    /**
76
     * Internal constructor. Use one of the factory methods to create a Note.
77
     *
78
     * @see Note::fromCents()
79
     * @see Note::fromFrequency()
80
     * @see Note::fromName()
81
     *
82
     * @param string $name         The note name (A-G).
83
     * @param string $accidental   The accidental (one of the Note::ACCIDENTAL_
84
     *                             constants).
85
     * @param int    $octave       The octave, in scientific pitch notation.
86
     * @param float  $difference   The note's difference in cents from 12-TET.
87
     */
88
    private function __construct(string $name, string $accidental, int $octave, float $difference)
89
    {
90
        if ($octave > 1000) {
91
            throw new \InvalidArgumentException(sprintf('Invalid octave: %d', $octave));
92
        }
93
94
        $this->name = $name;
95
        $this->accidental = $accidental;
96
        $this->octave = $octave;
97
        $this->difference = $difference;
98
    }
99
100
    /**
101
     * Instantiate a Note from a number of cents.
102
     *
103
     * @param float    $cents                A number of cents above C4.
104
     * @param string[] $preferredAccidentals A list of accidentals in order of
105
     *                                       preference. This will be merged
106
     *                                       with a default list.
107
     *
108
     * @return self
109
     */
110
    public static function fromCents(float $cents, array $preferredAccidentals = []): self
111
    {
112
        $rounded = (int) round($cents / 50) * 50;
113
        $difference = $cents - $rounded;
114
        $octave = (int) floor($rounded / 1200) + 4;
115
        $centsWithoutOctave = $rounded - (($octave - 4) * 1200);
116
        $preferredAccidentals = array_merge($preferredAccidentals, self::$preferredAccidentals);
117
        foreach ($preferredAccidentals as $accidental) {
118
            $accidentalCents = self::$accidentalCents[$accidental];
119
            if (($name = array_search($centsWithoutOctave - $accidentalCents, self::$names, true)) !== false) {
120
                return new self((string) $name, $accidental, $octave, $difference);
121
            }
122
        }
123
124
        throw new \InvalidArgumentException(sprintf('Failed to find note name for cents: %d', $cents)); // @codeCoverageIgnore
125
    }
126
127
    /**
128
     * Instantiate a Note from a note name.
129
     *
130
     * @param string $name A note name with an accidental and an octave in
131
     *                     scientific pitch notation, e.g. C#4 or Eb5.
132
     *
133
     * @return \ExtendedStrings\Strings\Note
134
     */
135
    public static function fromName(string $name): self
136
    {
137
        $original = $name;
138
        if (!preg_match('/^[a-g]/i', $name, $matches)) {
139
            throw new \InvalidArgumentException(sprintf('Invalid note name: %s', $original));
140
        }
141
        $noteName = strtoupper($matches[0]);
142
        $name = substr($name, strlen($matches[0]));
143
        if (preg_match('/^\-[0-9]+$/i', $name)) {
144
            throw new \InvalidArgumentException(sprintf(
145
                'Ambiguous note: %s (does "-" mean a quarter-flat or a negative?)',
146
                $original
147
            ));
148
        }
149
        $octave = 4;
150
        $difference = 0;
151
        if (preg_match('/\/?(\-?[0-9]+)?( ([\+-][0-9]+)[c¢])?$/iu', $name, $matches)) {
152
            if (isset($matches[1])) {
153
                $octave = intval($matches[1]);
154
            }
155
            if (isset($matches[3])) {
156
                $difference = intval($matches[3]);
157
            }
158
            $name = substr($name, 0, strlen($name) - strlen($matches[0]));
159
        }
160
        $accidental = self::normalizeAccidental($name);
161
162
        return new self($noteName, $accidental, $octave, $difference);
163
    }
164
165
    /**
166
     * Instantiate a Note from a frequency.
167
     *
168
     * @param float $frequency            The frequency (in Hz).
169
     * @param float $A4                   The frequency of A4, for reference.
170
     * @param array $preferredAccidentals Some preferred accidentals.
171
     *
172
     * @return self
173
     */
174
    public static function fromFrequency($frequency, float $A4 = 440.0, array $preferredAccidentals = []): self
175
    {
176
        return self::fromCents(Cent::frequencyToCents($frequency, $A4), $preferredAccidentals);
177
    }
178
179
    /**
180
     * Returns the note as a number of cents above C4.
181
     *
182
     * @return float
183
     */
184
    public function getCents(): float
185
    {
186
        return self::$names[$this->name]
187
            + self::$accidentalCents[$this->accidental]
188
            + (($this->octave - 4) * 1200)
189
            + $this->difference;
190
    }
191
192
    /**
193
     * Returns the note as a frequency.
194
     *
195
     * @param float $A4 The frequency of A4 (in Hz), for reference.
196
     *
197
     * @return float
198
     */
199
    public function getFrequency(float $A4 = 440.0): float
200
    {
201
        return Cent::centsToFrequency($this->getCents() - 900, $A4);
202
    }
203
204
    /**
205
     * Returns a string representation of the note.
206
     *
207
     * @return string
208
     */
209
    public function __toString(): string
210
    {
211
        $output = sprintf('%s%s%d', $this->name, $this->accidental, $this->octave);
212
        if ((int) round($this->difference) !== 0) {
213
            $output .= sprintf(' %+d¢', round($this->difference));
214
        }
215
216
        return $output;
217
    }
218
219
    /**
220
     * Returns the simple note name (one of A-G).
221
     *
222
     * @return string
223
     */
224
    public function getName(): string
225
    {
226
        return $this->name;
227
    }
228
229
    /**
230
     * Returns the accidental (one of the Note::ACCIDENTAL_ constants).
231
     *
232
     * @return string
233
     */
234
    public function getAccidental(): string
235
    {
236
        return $this->accidental;
237
    }
238
239
    /**
240
     * Returns the octave (in scientific pitch notation).
241
     *
242
     * @return int
243
     */
244
    public function getOctave(): int
245
    {
246
        return $this->octave;
247
    }
248
249
    /**
250
     * Returns the difference between the note and its 12-TET form, in cents.
251
     *
252
     * @return float
253
     */
254
    public function getDifference(): float
255
    {
256
        return $this->difference;
257
    }
258
259
    /**
260
     * @param string $accidental
261
     *
262
     * @return string
263
     */
264
    private static function normalizeAccidental(string $accidental): string
265
    {
266
        $accidental = trim($accidental);
267
268
        foreach (self::$accidentalPatterns as $pattern => $replacement) {
269
            if ($accidental === $replacement || preg_match('/^' . $pattern . '$/iu', $accidental)) {
270
                return $replacement;
271
            }
272
        }
273
274
        throw new \InvalidArgumentException(sprintf('Invalid accidental: %s', $accidental));
275
    }
276
}
277