Source code for idtap.classes.pitch

from typing import List, Dict, TypedDict, Optional, Union
import humps
import math

# this should all be implemented in snake_case, even though the TypeScript 
# version is in camelCase

class PitchOptionsType(TypedDict, total=False):
    swara: str | int
    oct: int
    raised: bool
    fundamental: float
    ratios: list[float | list[float]]
    log_offset: float

[docs] class Pitch:
[docs] def __init__(self, options: Optional[PitchOptionsType] = None): if options is None: options = {} else: # convert camelCase incoming keys to snake_case options = humps.decamelize(options) self.log_offset = options.get('log_offset', 0.0) self.sargam = ['sa', 're', 'ga', 'ma', 'pa', 'dha', 'ni'] self.sargam_letters = [s[0] for s in self.sargam] ratios_default = [ 1, [2 ** (1 / 12), 2 ** (2 / 12)], [2 ** (3 / 12), 2 ** (4 / 12)], [2 ** (5 / 12), 2 ** (6 / 12)], 2 ** (7 / 12), [2 ** (8 / 12), 2 ** (9 / 12)], [2 ** (10 / 12), 2 ** (11 / 12)] ] self.ratios = options.get('ratios', ratios_default) # validate ratios for undefined values (None) for r in self.ratios: if isinstance(r, list): for sub in r: if sub is None: raise SyntaxError(f"invalid ratio type, must be float: {sub}") else: if r is None: raise SyntaxError(f"invalid ratio type, must be float: {r}") raised = options.get('raised', True) if not isinstance(raised, bool): raise SyntaxError(f"invalid raised type, must be boolean: {raised}") self.raised = raised swara = options.get('swara', 'sa') if isinstance(swara, str): swara = swara.lower() if len(swara) > 1: if swara not in self.sargam: raise SyntaxError(f"invalid swara string: \"{swara}\"") self.swara = self.sargam.index(swara) elif len(swara) == 1: if swara not in self.sargam_letters: raise SyntaxError(f"invalid swara string: \"{swara}\"") self.swara = self.sargam_letters.index(swara) elif isinstance(swara, int): if swara < 0 or swara > len(self.sargam) - 1: raise SyntaxError(f"invalid swara number: {swara}") self.swara = swara else: raise SyntaxError(f"invalad swara type: {swara}, {type(swara)}") if not isinstance(self.swara, int): raise SyntaxError(f"invalid swara type: {self.swara}") octv = options.get('oct', 0) if not isinstance(octv, int): raise SyntaxError(f"invalid oct type: {octv}") self.oct = octv fundamental = options.get('fundamental', 261.63) if not isinstance(fundamental, (int, float)): raise SyntaxError(f"invalid fundamental type, must be float: {fundamental}") self.fundamental = float(fundamental) # raised override for sa and pa if self.swara in (0, 4): self.raised = True
def __eq__(self, other): if not isinstance(other, Pitch): return False else: return (self.swara == other.swara and self.raised == other.raised and self.oct == other.oct ) @property def frequency(self): if not isinstance(self.swara, int): raise SyntaxError(f"wrong swara type, must be number: {self.swara}") if self.swara in (0, 4): ratio = self.ratios[self.swara] if not isinstance(ratio, (int, float)): raise SyntaxError(f"invalid ratio type, must be float: {ratio}") else: nested = self.ratios[self.swara] if not isinstance(nested, list): raise SyntaxError( f"invalid nestedRatios type, must be array: {nested}") ratio = nested[int(self.raised)] if not isinstance(ratio, (int, float)): raise SyntaxError(f"invalid ratio type, must be float: {ratio}") return self.fundamental * ratio * (2 ** self.oct) * (2 ** self.log_offset) @property def non_offset_frequency(self): if not isinstance(self.swara, int): raise SyntaxError(f"wrong swara type, must be number: {self.swara}") if self.swara in (0, 4): ratio = self.ratios[self.swara] if not isinstance(ratio, (int, float)): raise SyntaxError(f"invalid ratio type, must be float: {ratio}") else: nested = self.ratios[self.swara] if not isinstance(nested, list): raise SyntaxError( f"invalid nestedRatios type, must be array: {nested}") ratio = nested[int(self.raised)] if not isinstance(ratio, (int, float)): raise SyntaxError(f"invalid ratio type, must be float: {ratio}") return self.fundamental * ratio * (2 ** self.oct) @property def non_offset_log_freq(self): return math.log2(self.non_offset_frequency) @property def log_freq(self): return math.log2(self.frequency) @property def sargam_letter(self): sargam = ['sa', 're', 'ga', 'ma', 'pa', 'dha', 'ni'] s = sargam[int(self.swara)][0] if self.swara == 0 or self.swara == 4: # raised override self.raised = True if self.raised: s = s.upper() # Ensure the first letter is capitalized return s @property def octaved_sargam_letter(self): s = self.sargam_letter if (self.oct == -2): s = s + '\u0324' elif (self.oct == -1): s = s + '\u0323' elif (self.oct == 1): s = s + '\u0307' elif (self.oct == 2): s = s + '\u0308' return s @property def numbered_pitch(self): # something like a midi pitch, but centered on 0 instead of 60 if not isinstance(self.swara, int): raise SyntaxError(f"invalid swara: {self.swara}") if self.swara < 0 or self.swara > 6: raise SyntaxError(f"invalid swara: {self.swara}") if self.swara == 0: return self.oct * 12 + 0 elif self.swara == 1: return self.oct * 12 + 1 + int(self.raised) elif self.swara == 2: return self.oct * 12 + 3 + int(self.raised) elif self.swara == 3: return self.oct * 12 + 5 + int(self.raised) elif self.swara == 4: return self.oct * 12 + 7 elif self.swara == 5: return self.oct * 12 + 8 + int(self.raised) elif self.swara == 6: return self.oct * 12 + 10 + int(self.raised) else: raise SyntaxError(f"invalid swara: {self.swara}") @property def chroma(self): np = self.numbered_pitch while np < 0: np += 12 return np % 12 #method
[docs] def to_json(self): return { # this should still be camelCase 'swara': self.swara, 'raised': self.raised, 'oct': self.oct, 'logOffset': self.log_offset, }
#method
[docs] def set_oct(self, new_oct): self.oct = new_oct ratio = None if self.swara == 0 or self.swara == 4: ratio = self.ratios[self.swara] if not isinstance(ratio, (int, float)): raise SyntaxError(f"Invalid ratio type, must be int or float: {ratio}") else: if not isinstance(self.swara, int): raise SyntaxError(f"Invalid swara type: {self.swara}") nested_ratios = self.ratios[self.swara] if not isinstance(nested_ratios, list): raise SyntaxError(f"Invalid nested_ratios type, must be array: {nested_ratios}") ratio = nested_ratios[int(self.raised)]
# ------------------------------------------------------------------ # additional helpers and display properties mirroring pitch.ts
[docs] @staticmethod def pitch_number_to_chroma(pitch_number: int) -> int: chroma = pitch_number % 12 while chroma < 0: chroma += 12 return chroma
[docs] @staticmethod def chroma_to_scale_degree(chroma: int) -> tuple[int, bool]: mapping = { 0: (0, True), 1: (1, False), 2: (1, True), 3: (2, False), 4: (2, True), 5: (3, False), 6: (3, True), 7: (4, True), 8: (5, False), 9: (5, True), 10: (6, False), 11: (6, True), } return mapping[chroma]
[docs] @staticmethod def from_pitch_number(pitch_number: int, fundamental: float = 261.63) -> "Pitch": octv = math.floor(pitch_number / 12) chroma = Pitch.pitch_number_to_chroma(pitch_number) swara, raised = Pitch.chroma_to_scale_degree(chroma) return Pitch({ 'swara': swara, 'oct': octv, 'raised': raised, 'fundamental': fundamental })
# ------------------------------------------------------------------ @property def solfege_letter(self) -> str: solfege = [ 'Do', 'Ra', 'Re', 'Me', 'Mi', 'Fa', 'Fi', 'Sol', 'Le', 'La', 'Te', 'Ti' ] return solfege[self.chroma] @property def scale_degree(self) -> int: return int(self.swara) + 1 def _octave_diacritic(self) -> str: mapping = { -3: '\u20E8', -2: '\u0324', -1: '\u0323', 1: '\u0307', 2: '\u0308', 3: '\u20DB' } return mapping.get(self.oct, '') def _octave_latex_diacritic(self) -> str: """Convert octave to LaTeX math notation for proper diacritic positioning.""" mapping = { -3: r'\underset{\cdot\cdot\cdot}', # Triple dot below -2: r'\underset{\cdot\cdot}', # Double dot below -1: r'\underset{\cdot}', # Single dot below 1: r'\dot', # Single dot above 2: r'\ddot', # Double dot above 3: r'\dddot' # Triple dot above } return mapping.get(self.oct, '') @property def octaved_scale_degree(self) -> str: return f"{self.scale_degree}{self._octave_diacritic()}" @property def octaved_sargam_letter(self) -> str: return f"{self.sargam_letter}{self._octave_diacritic()}" @property def octaved_sargam_letter_with_cents(self) -> str: cents = self.cents_string return f"{self.octaved_sargam_letter} ({cents})" @property def octaved_solfege_letter(self) -> str: return f"{self.solfege_letter}{self._octave_diacritic()}" @property def octaved_solfege_letter_with_cents(self) -> str: cents = self.cents_string return f"{self.octaved_solfege_letter} ({cents})" @property def octaved_chroma(self) -> str: return f"{self.chroma}{self._octave_diacritic()}" @property def octaved_chroma_with_cents(self) -> str: cents = self.cents_string return f"{self.octaved_chroma} ({cents})" @property def cents_string(self) -> str: et_freq = self.fundamental * 2 ** (self.chroma / 12) * 2 ** self.oct cents = 1200 * math.log2(self.frequency / et_freq) sign = '+' if cents >= 0 else '-' return f"{sign}{round(abs(cents))}\u00A2" @property def latex_sargam_letter(self) -> str: """LaTeX-compatible base sargam letter.""" return self.sargam_letter @property def latex_octaved_sargam_letter(self) -> str: """LaTeX math mode sargam letter with properly positioned diacritics.""" base_letter = self.sargam_letter latex_diacritic = self._octave_latex_diacritic() if not latex_diacritic: return base_letter # No octave marking elif latex_diacritic.startswith(r'\underset'): return f'${latex_diacritic}{{\\mathrm{{{base_letter}}}}}$' else: return f'${latex_diacritic}{{\\mathrm{{{base_letter}}}}}$' @property def a440_cents_deviation(self) -> str: c0 = 16.3516 deviation = 1200 * math.log2(self.frequency / c0) octv = math.floor(deviation / 1200) pitch_idx = round((deviation % 1200) / 100) cents = round(deviation % 100) sign = '+' if cents > 50: cents = 100 - cents sign = '-' pitch_idx = (pitch_idx + 1) % 12 pitch = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'][pitch_idx] return f"{pitch}{octv} ({sign}{cents}\u00A2)" @property def western_pitch(self) -> str: pitch = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'][self.chroma] return pitch @property def movable_c_cents_deviation(self) -> str: pitch = self.western_pitch et_freq = self.fundamental * 2 ** (self.chroma / 12) * 2 ** self.oct cents = 1200 * math.log2(self.frequency / et_freq) sign = '+' if cents >= 0 else '-' return f"{pitch} ({sign}{round(abs(cents))}\u00A2)"
[docs] def same_as(self, other: "Pitch") -> bool: return self.swara == other.swara and self.oct == other.oct and self.raised == other.raised
[docs] @classmethod def from_json(cls, obj: dict, ratios=None, fundamental=None) -> "Pitch": opts = dict(obj) if ratios is not None: opts['ratios'] = ratios if fundamental is not None: opts['fundamental'] = fundamental return cls(opts)