platypush/platypush/plugins/sound/_streams/_player/_synth/_parser.py

112 lines
3.0 KiB
Python

import math
import re
from typing import Optional, Union
class SoundParser:
"""
A utility mixin with some methods to parse and convert sound information -
e.g. MIDI notes from strings, MIDI notes to frequencies, and the other way
around.
"""
_DEFAULT_A4_FREQUENCY = 440.0
_MIDI_NOTE_REGEX = re.compile(r'^([A-G])([#b]?)(-?[0-9]+)$')
_MID_A_MIDI_NOTE = 69
_NOTE_OFFSETS = {
'C': 0,
'C#': 1,
'Db': 1,
'D': 2,
'D#': 3,
'Eb': 3,
'E': 4,
'F': 5,
'F#': 6,
'Gb': 6,
'G': 7,
'G#': 8,
'Ab': 8,
'A': 9,
'A#': 10,
'Bb': 10,
'B': 11,
}
_ALTERATION_OFFSETS = {
'b': -1,
'': 0,
'#': 1,
}
def __init__(self, *_, ref_frequency: float = _DEFAULT_A4_FREQUENCY, **__) -> None:
self._ref_frequency = ref_frequency
@staticmethod
def _get_alteration_offset(alt: str) -> int:
"""
Calculate the MIDI note offset given by its reported sharp/flat alteration.
"""
if alt == '#':
return 1
if alt == 'b':
return -1
return 0
@classmethod
def get_midi_note(cls, note: Union[str, int]) -> int:
"""
Convert a MIDI note given as input (either an integer or a string like
'C4') to a MIDI note number.
:raise: ValueError
"""
if isinstance(note, str):
note = note[:1].upper() + note[1:]
m = cls._MIDI_NOTE_REGEX.match(note)
if not m:
raise ValueError(f'Invalid MIDI note: {note}')
base_note, alteration, octave = m.groups()
octave = int(octave)
note_offset = cls._NOTE_OFFSETS[base_note] + cls._get_alteration_offset(
alteration
)
octave_offset = (octave + 1) * 12
note = octave_offset + note_offset
if isinstance(note, int):
if not 0 <= note <= 127:
raise ValueError(f'MIDI note out of range: {note}')
return note
raise ValueError(f'Invalid MIDI note: {note}')
def note_to_freq(
self, midi_note: Union[int, str], ref_frequency: Optional[float] = None
):
"""
Converts a MIDI note to its frequency in Hz
:param midi_note: MIDI note to convert
:param ref_frequency: Reference A4 frequency override (default: 440 Hz).
"""
note = self.get_midi_note(midi_note)
return (2.0 ** ((note - self._MID_A_MIDI_NOTE) / 12.0)) * (
ref_frequency or self._ref_frequency
)
def freq_to_note(self, frequency: float, ref_frequency: Optional[float] = None):
"""
Converts a frequency in Hz to its closest MIDI note
:param frequency: Frequency in Hz
:param ref_frequency: Reference A4 frequency override (default: 440 Hz).
"""
std_freq = ref_frequency or self._ref_frequency
return int(12.0 * math.log(frequency / std_freq, 2) + self._MID_A_MIDI_NOTE)