from __future__ import annotations
import math
import uuid
from typing import List, Dict, Optional, Callable, TypedDict
import humps
from .pitch import Pitch
from .articulation import Articulation
from .automation import Automation, get_starts
from ..enums import Instrument
class VibObjType(TypedDict, total=False):
periods: int
vert_offset: float
init_up: bool
extent: float
[docs]
class Trajectory:
[docs]
def __init__(self, options: Optional[dict] = None) -> None:
opts = humps.decamelize(options or {})
# Parameter validation
self._validate_parameters(opts)
self.names = [
'Fixed',
'Bend: Simple',
'Bend: Sloped Start',
'Bend: Sloped End',
'Bend: Ladle',
'Bend: Reverse Ladle',
'Bend: Simple Multiple',
'Krintin',
'Krintin Slide',
'Krintin Slide Hammer',
'Dense Krintin Slide Hammer',
'Slide',
'Silent',
'Vibrato'
]
id_val = opts.get('id', 0)
if not isinstance(id_val, int):
raise SyntaxError(f'invalid id type, must be int: {id_val}')
self.id: int = id_val
pitches_in = opts.get('pitches', [Pitch()])
if not isinstance(pitches_in, list) or not all(isinstance(p, Pitch) for p in pitches_in):
raise SyntaxError('invalid pitches type, must be array of Pitch: ' + str(pitches_in))
self.pitches: List[Pitch] = pitches_in
dur_tot = opts.get('dur_tot', 1.0)
if not isinstance(dur_tot, (int, float)):
raise SyntaxError(f'invalid durTot type, must be number: {dur_tot}')
self.dur_tot: float = float(dur_tot)
self.dur_array: Optional[List[float]] = opts.get('dur_array')
slope = opts.get('slope')
if slope is None:
self.slope = 2.0
elif isinstance(slope, (int, float)):
self.slope = float(slope)
else:
raise SyntaxError(f'invalid slope type, must be number: {slope}')
vib_obj = opts.get('vib_obj')
if vib_obj is None:
self.vib_obj: VibObjType = {
'periods': 8,
'vert_offset': 0,
'init_up': True,
'extent': 0.05,
}
else:
# Normalize and validate vib_obj for flexible inputs (e.g., strings)
self.vib_obj = self._normalize_vib_obj(vib_obj) # type: ignore
instr = opts.get('instrumentation', Instrument.Sitar)
self.instrumentation: Instrument = instr
articulations_in = opts.get('articulations')
if articulations_in is None:
if self.instrumentation == Instrument.Sitar:
self.articulations: Dict[str, Articulation] = {
'0.00': Articulation({'name': 'pluck', 'stroke': 'd'})
}
else:
self.articulations = {}
else:
if not isinstance(articulations_in, dict):
raise SyntaxError(f'invalid articulations type, must be object: {articulations_in}')
self.articulations = {}
for k, v in articulations_in.items():
if not isinstance(v, Articulation):
v = Articulation(v) # type: ignore
self.articulations[str(k)] = v
self.num = opts.get('num')
self.name = opts.get('name')
self.name = self.name_
self.ids: List[Callable[[float], float]] = []
for i in range(14):
if i == 11:
self.ids.append(self.id7)
else:
self.ids.append(getattr(self, f'id{i}'))
self.fund_id12 = opts.get('fund_id12')
self.structured_names = {
'fixed': 0,
'bend': {
'simple': 1,
'sloped start': 2,
'sloped end': 3,
'ladle': 4,
'reverse ladle': 5,
'yoyo': 6,
},
'krintin': {
'krintin': 7,
'krintin slide': 8,
'krintin slide hammer': 9,
'spiffy krintin slide hammer': 10,
},
'slide': 11,
'silent': 12,
'vibrato': 13,
}
self.vowel = opts.get('vowel')
self.vowel_ipa = opts.get('vowel_ipa')
self.vowel_hindi = opts.get('vowel_hindi')
self.vowel_eng_trans = opts.get('vowel_eng_trans')
self.start_consonant = opts.get('start_consonant')
self.start_consonant_hindi = opts.get('start_consonant_hindi')
self.start_consonant_ipa = opts.get('start_consonant_ipa')
self.start_consonant_eng_trans = opts.get('start_consonant_eng_trans')
self.end_consonant = opts.get('end_consonant')
self.end_consonant_hindi = opts.get('end_consonant_hindi')
self.end_consonant_ipa = opts.get('end_consonant_ipa')
self.end_consonant_eng_trans = opts.get('end_consonant_eng_trans')
self.group_id = opts.get('group_id')
automation_in = opts.get('automation')
if automation_in is not None:
if isinstance(automation_in, Automation):
self.automation = automation_in
else:
self.automation = Automation(automation_in)
elif self.id == 12:
self.automation = None
else:
self.automation = Automation()
if self.start_consonant is not None:
self.articulations['0.00'] = Articulation({
'name': 'consonant',
'stroke': self.start_consonant,
'hindi': self.start_consonant_hindi,
'ipa': self.start_consonant_ipa,
})
if self.end_consonant is not None:
self.articulations['1.00'] = Articulation({
'name': 'consonant',
'stroke': self.end_consonant,
'hindi': self.end_consonant_hindi,
'ipa': self.end_consonant_ipa,
})
if self.id < 4:
self.dur_array = [1]
elif self.dur_array is None and self.id == 4:
self.dur_array = [1/3, 2/3]
elif self.dur_array is None and self.id == 5:
self.dur_array = [2/3, 1/3]
elif self.dur_array is None and self.id == 6:
if len(self.log_freqs) > 1:
self.dur_array = [1/(len(self.log_freqs)-1)] * (len(self.log_freqs)-1)
else:
self.dur_array = []
elif self.id == 7:
if self.dur_array is None:
self.dur_array = [0.2, 0.8]
starts = get_starts(self.dur_array)
cond = len(self.log_freqs) > 1 and self.log_freqs[1] >= self.log_freqs[0]
self.articulations[str(starts[1])] = Articulation({
'name': 'hammer-on' if cond else 'hammer-off'
})
elif self.id == 8:
if self.dur_array is None:
self.dur_array = [1/3,1/3,1/3]
starts = get_starts(self.dur_array)
self.articulations[str(starts[1])] = Articulation({'name': 'hammer-off'})
self.articulations[str(starts[2])] = Articulation({'name': 'slide'})
elif self.id == 9:
if self.dur_array is None:
self.dur_array = [0.25,0.25,0.25,0.25]
starts = get_starts(self.dur_array)
self.articulations[str(starts[1])] = Articulation({'name': 'hammer-off'})
self.articulations[str(starts[2])] = Articulation({'name': 'slide'})
self.articulations[str(starts[3])] = Articulation({'name': 'hammer-on'})
elif self.id == 10:
if self.dur_array is None:
self.dur_array = [1/6]*6
starts = get_starts(self.dur_array)
self.articulations[str(starts[1])] = Articulation({'name': 'slide'})
self.articulations[str(starts[2])] = Articulation({'name': 'hammer-on'})
self.articulations[str(starts[3])] = Articulation({'name': 'hammer-off'})
self.articulations[str(starts[4])] = Articulation({'name': 'slide'})
self.articulations[str(starts[5])] = Articulation({'name': 'hammer-on'})
elif self.id == 11:
if self.dur_array is None or len(self.dur_array) == 1:
self.dur_array = [0.5,0.5]
starts = get_starts(self.dur_array)
self.articulations[str(starts[1])] = Articulation({'name': 'slide'})
if self.dur_array:
i = 0
while i < len(self.dur_array):
if self.dur_array[i] == 0:
print('removing zero dur')
self.dur_array.pop(i)
if i+1 < len(self.pitches):
self.pitches.pop(i+1)
else:
i += 1
if self.instrumentation in (Instrument.Vocal_M, Instrument.Vocal_F):
for k in list(self.articulations.keys()):
if self.articulations[k].name == 'pluck':
del self.articulations[k]
self.c_ipas = ['k', 'kʰ', 'g', 'gʱ', 'ŋ', 'c', 'cʰ', 'ɟ', 'ɟʱ', 'ɲ', 'ʈ',
'ʈʰ', 'ɖ', 'ɖʱ', 'n', 't', 'tʰ', 'd', 'dʱ', 'n̪', 'p', 'pʰ', 'b', 'bʱ',
'm', 'j', 'r', 'l', 'v', 'ʃ', 'ʂ', 's', 'h']
self.c_isos = ['ka', 'kha', 'ga', 'gha', 'ṅa', 'ca', 'cha', 'ja', 'jha', 'ña', 'ṭa',
'ṭha', 'ḍa', 'ḍha', 'na', 'ta', 'tha', 'da', 'dha', 'na', 'pa', 'pha',
'ba', 'bha', 'ma', 'ya', 'ra', 'la', 'va', 'śa', 'ṣa', 'sa', 'ha']
self.c_hindis = ['क', 'ख', 'ग', 'घ', 'ङ', 'च', 'छ', 'ज', 'झ', 'ञ', 'ट',
'ठ', 'ड', 'ढ', 'न', 'त', 'थ', 'द', 'ध', 'न', 'प', 'फ़', 'ब', 'भ', 'म', 'य',
'र', 'ल', 'व', 'श', 'ष', 'स', 'ह']
self.c_eng_trans = ['k', 'kh', 'g', 'gh', 'ṅ', 'c', 'ch', 'j', 'jh', 'ñ', 'ṭ',
'ṭh', 'ḍ', 'ḍh', 'n', 't', 'th', 'd', 'dh', 'n', 'p', 'ph', 'b', 'bh',
'm', 'y', 'r', 'l', 'v', 'ś', 'ṣ', 's', 'h']
self.v_ipas = ['ə', 'aː', 'ɪ', 'iː', 'ʊ', 'uː', 'eː', 'ɛː', 'oː', 'ɔː', '_']
self.v_isos = ['a', 'ā', 'i', 'ī', 'u', 'ū', 'ē', 'ai', 'ō', 'au', '_']
self.v_hindis = ['अ', 'आ', 'इ', 'ई', 'उ', 'ऊ', 'ए', 'ऐ', 'ओ', 'औ', '_']
self.v_eng_trans = ['a', 'ā', 'i', 'ī', 'u', 'ū', 'ē', 'ai', 'ō', 'au', '_']
self.unique_id = opts.get('unique_id') or str(uuid.uuid4())
self.convert_c_iso_to_hindi_and_ipa()
for k in list(self.articulations.keys()):
if k == '0':
self.articulations['0.00'] = self.articulations[k]
del self.articulations[k]
self.tags = opts.get('tags', [])
self.start_time: Optional[float] = opts.get('start_time')
self.phrase_idx: Optional[int] = None
def _validate_parameters(self, opts: dict) -> None:
"""Validate constructor parameters and provide helpful error messages."""
if not opts:
return
# Define allowed parameter names
allowed_keys = {
'id', 'pitches', 'dur_tot', 'dur_array', 'slope', 'vib_obj', 'instrumentation',
'articulations', 'num', 'name', 'fund_id12', 'vowel', 'vowel_ipa', 'vowel_hindi',
'vowel_eng_trans', 'start_consonant', 'start_consonant_hindi', 'start_consonant_ipa',
'start_consonant_eng_trans', 'end_consonant', 'end_consonant_hindi', 'end_consonant_ipa',
'end_consonant_eng_trans', 'group_id', 'automation', 'unique_id', 'tags', 'start_time',
'phrase_idx'
}
provided_keys = set(opts.keys())
invalid_keys = provided_keys - allowed_keys
# Check for invalid parameter names with helpful suggestions
if invalid_keys:
error_messages = []
for key in invalid_keys:
if key == 'type':
error_messages.append(f"Parameter '{key}' not supported. Did you mean 'id'?")
elif key == 'duration':
error_messages.append(f"Parameter '{key}' not supported. Did you mean 'dur_tot'?")
elif key == 'instrument':
error_messages.append(f"Parameter '{key}' not supported. Did you mean 'instrumentation'?")
elif key == 'duration_array':
error_messages.append(f"Parameter '{key}' not supported. Did you mean 'dur_array'?")
elif key == 'vibrato_obj':
error_messages.append(f"Parameter '{key}' not supported. Did you mean 'vib_obj'?")
elif key == 'fundamental_id12':
error_messages.append(f"Parameter '{key}' not supported. Did you mean 'fund_id12'?")
else:
error_messages.append(f"Invalid parameter: '{key}'")
error_msg = "; ".join(error_messages)
error_msg += f". Allowed parameters: {sorted(allowed_keys)}"
raise ValueError(error_msg)
# Validate parameter types and values
self._validate_parameter_types(opts)
self._validate_parameter_values(opts)
def _validate_parameter_types(self, opts: dict) -> None:
"""Validate that all parameters have correct types.
Note: Some parameters (id, pitches, dur_tot, slope, articulations) are validated
by the original constructor logic which throws SyntaxError, so we skip them here."""
# Skip parameters that are already validated by original constructor logic:
# - id, pitches, dur_tot, slope, articulations (validated with SyntaxError)
if 'dur_array' in opts and opts['dur_array'] is not None:
if not isinstance(opts['dur_array'], list):
raise TypeError(f"Parameter 'dur_array' must be a list, got {type(opts['dur_array']).__name__}")
if not all(isinstance(d, (int, float)) for d in opts['dur_array']):
raise TypeError("All items in 'dur_array' must be numbers")
if 'vib_obj' in opts and opts['vib_obj'] is not None:
if not isinstance(opts['vib_obj'], dict):
raise TypeError(f"Parameter 'vib_obj' must be a dict, got {type(opts['vib_obj']).__name__}")
self._validate_vib_obj_structure(opts['vib_obj'])
if 'instrumentation' in opts and not isinstance(opts['instrumentation'], Instrument):
raise TypeError(f"Parameter 'instrumentation' must be an Instrument enum, got {type(opts['instrumentation']).__name__}")
if 'start_time' in opts and opts['start_time'] is not None:
if not isinstance(opts['start_time'], (int, float)):
raise TypeError(f"Parameter 'start_time' must be a number, got {type(opts['start_time']).__name__}")
# Validate string parameters
string_params = ['name', 'vowel', 'vowel_ipa', 'vowel_hindi', 'vowel_eng_trans',
'start_consonant', 'start_consonant_hindi', 'start_consonant_ipa',
'start_consonant_eng_trans', 'end_consonant', 'end_consonant_hindi',
'end_consonant_ipa', 'end_consonant_eng_trans', 'unique_id']
for param in string_params:
if param in opts and opts[param] is not None and not isinstance(opts[param], str):
raise TypeError(f"Parameter '{param}' must be a string, got {type(opts[param]).__name__}")
if 'tags' in opts and not isinstance(opts['tags'], list):
raise TypeError(f"Parameter 'tags' must be a list, got {type(opts['tags']).__name__}")
def _validate_parameter_values(self, opts: dict) -> None:
"""Validate that parameter values are in valid ranges.
Note: Some parameters (id, dur_tot, slope) are validated by original constructor,
so we only do additional range checks here."""
# Additional range validation for parameters already type-checked by original constructor
if 'id' in opts and isinstance(opts['id'], int):
if not 0 <= opts['id'] <= 13:
raise ValueError(f"Parameter 'id' must be between 0-13 (trajectory types), got {opts['id']}")
if 'dur_tot' in opts and isinstance(opts['dur_tot'], (int, float)):
if opts['dur_tot'] <= 0:
raise ValueError(f"Parameter 'dur_tot' must be positive, got {opts['dur_tot']}")
if 'dur_array' in opts and opts['dur_array'] is not None:
dur_array = opts['dur_array']
if any(d < 0 for d in dur_array):
raise ValueError("All values in 'dur_array' must be non-negative")
if len(dur_array) > 0 and sum(dur_array) == 0:
raise ValueError("'dur_array' cannot have all zero values")
if 'slope' in opts and isinstance(opts['slope'], (int, float)):
if opts['slope'] <= 0:
raise ValueError(f"Parameter 'slope' must be positive, got {opts['slope']}")
if 'start_time' in opts and opts['start_time'] is not None:
if opts['start_time'] < 0:
raise ValueError(f"Parameter 'start_time' must be non-negative, got {opts['start_time']}")
# Validate vocal parameters are only used with vocal instruments
vocal_params = ['vowel', 'vowel_ipa', 'vowel_hindi', 'vowel_eng_trans',
'start_consonant', 'start_consonant_hindi', 'start_consonant_ipa',
'start_consonant_eng_trans', 'end_consonant', 'end_consonant_hindi',
'end_consonant_ipa', 'end_consonant_eng_trans']
has_vocal_params = any(param in opts and opts[param] is not None for param in vocal_params)
instrumentation = opts.get('instrumentation', Instrument.Sitar)
if has_vocal_params and instrumentation not in (Instrument.Vocal_M, Instrument.Vocal_F):
import warnings
warnings.warn(f"Vocal parameters provided but instrumentation is {instrumentation.name}. "
"Vocal parameters are typically used with Vocal_M or Vocal_F instruments.", UserWarning)
def _validate_vib_obj_structure(self, vib_obj: dict) -> None:
"""Validate vib_obj has correct structure, allowing lenient input types.
Accepts numeric strings and floats that can be coerced to required types,
but does not mutate the provided dict. Actual coercion happens in
_normalize_vib_obj.
"""
allowed_keys = {'periods', 'vert_offset', 'init_up', 'extent'}
provided_keys = set(vib_obj.keys())
invalid_keys = provided_keys - allowed_keys
if invalid_keys:
raise ValueError(
f"vib_obj contains invalid keys: {sorted(invalid_keys)}. "
f"Allowed keys: {sorted(allowed_keys)}"
)
# Validate types and values (lenient)
if 'periods' in vib_obj:
p = vib_obj['periods']
if isinstance(p, int):
if p <= 0:
raise ValueError("vib_obj['periods'] must be positive")
elif isinstance(p, float):
if p <= 0:
raise ValueError("vib_obj['periods'] must be positive")
elif isinstance(p, str):
try:
pf = float(p.strip())
except Exception as e:
raise TypeError("vib_obj['periods'] must be an integer or numeric string") from e
if pf <= 0:
raise ValueError("vib_obj['periods'] must be positive")
else:
raise TypeError("vib_obj['periods'] must be a number")
for key in ['vert_offset', 'extent']:
if key in vib_obj:
v = vib_obj[key]
if isinstance(v, (int, float)):
pass
elif isinstance(v, str):
try:
float(v.strip())
except Exception as e:
raise TypeError(f"vib_obj['{key}'] must be a number or numeric string") from e
else:
raise TypeError(f"vib_obj['{key}'] must be a number")
if 'extent' in vib_obj:
try:
ext_val = float(vib_obj['extent'])
except Exception:
# If not coercible, earlier checks will have raised
ext_val = 0.0
if ext_val <= 0:
raise ValueError("vib_obj['extent'] must be positive")
if 'init_up' in vib_obj:
iu = vib_obj['init_up']
if isinstance(iu, bool):
pass
elif isinstance(iu, (int, float)):
if iu not in (0, 1):
raise TypeError("vib_obj['init_up'] must be boolean-like (0/1)")
elif isinstance(iu, str):
if iu.strip().lower() not in {'true', 'false', '0', '1'}:
raise TypeError("vib_obj['init_up'] must be 'true'/'false' or '0'/'1'")
else:
raise TypeError("vib_obj['init_up'] must be a boolean or boolean-like string")
def _normalize_vib_obj(self, vib_obj: dict) -> VibObjType:
"""Return a normalized VibObjType with correct Python types.
- periods: int (>0)
- vert_offset: float
- extent: float (>0)
- init_up: bool
"""
# Start from defaults
norm: VibObjType = {
'periods': 8,
'vert_offset': 0.0,
'init_up': True,
'extent': 0.05,
}
# Validate structure leniently first
self._validate_vib_obj_structure(vib_obj)
# Coerce values
if 'periods' in vib_obj:
p = vib_obj['periods']
if isinstance(p, (int, float)):
norm['periods'] = int(p)
elif isinstance(p, str):
norm['periods'] = int(float(p.strip()))
if 'vert_offset' in vib_obj:
v = vib_obj['vert_offset']
if isinstance(v, (int, float)):
norm['vert_offset'] = float(v)
elif isinstance(v, str):
norm['vert_offset'] = float(v.strip())
if 'extent' in vib_obj:
e = vib_obj['extent']
if isinstance(e, (int, float)):
norm['extent'] = float(e)
elif isinstance(e, str):
norm['extent'] = float(e.strip())
if 'init_up' in vib_obj:
iu = vib_obj['init_up']
if isinstance(iu, bool):
norm['init_up'] = iu
elif isinstance(iu, (int, float)):
norm['init_up'] = bool(int(iu))
elif isinstance(iu, str):
sval = iu.strip().lower()
if sval in {'true', '1'}:
norm['init_up'] = True
elif sval in {'false', '0'}:
norm['init_up'] = False
else:
# Should not happen due to validation above
raise TypeError("vib_obj['init_up'] string must be 'true'/'false' or '0'/'1'")
# Final sanity checks
if norm['periods'] <= 0:
raise ValueError("vib_obj['periods'] must be positive after normalization")
if norm['extent'] <= 0:
raise ValueError("vib_obj['extent'] must be positive after normalization")
return norm
# ------------------------------- properties -----------------------------
@property
def freqs(self) -> List[float]:
return [p.frequency for p in self.pitches]
@property
def log_freqs(self) -> List[float]:
return [math.log2(p.frequency) for p in self.pitches]
@property
def sloped(self) -> bool:
return self.id in (2,3,4,5)
@property
def min_freq(self) -> float:
return min(self.freqs)
@property
def max_freq(self) -> float:
return max(self.freqs)
@property
def min_log_freq(self) -> float:
return min(self.log_freqs)
@property
def max_log_freq(self) -> float:
return max(self.log_freqs)
@property
def end_time(self) -> Optional[float]:
if self.start_time is None:
return None
return self.start_time + self.dur_tot
@property
def name_(self) -> str:
return self.names[self.id]
# ------------------------------- utils -----------------------------
[docs]
def update_fundamental(self, fundamental: float) -> None:
for p in self.pitches:
p.fundamental = fundamental
# ------------------------------- computation -----------------------
[docs]
def compute(self, x: float, log_scale: bool = False) -> float:
val = self.ids[self.id](x)
return math.log2(val) if log_scale else val
[docs]
def id0(self, x: float, lf: Optional[List[float]] = None) -> float:
log_freqs = lf or self.log_freqs
return 2 ** log_freqs[0]
[docs]
def id1(self, x: float, lf: Optional[List[float]] = None) -> float:
log_freqs = lf or self.log_freqs
pi_x = (math.cos(math.pi * (x + 1)) / 2) + 0.5
diff = log_freqs[1] - log_freqs[0]
return 2 ** (pi_x * diff + log_freqs[0])
[docs]
def id2(self, x: float, lf: Optional[List[float]] = None, sl: Optional[float] = None) -> float:
log_freqs = lf or self.log_freqs
slope = sl if sl is not None else self.slope
a = log_freqs[0]
b = log_freqs[1]
log_freq_out = (a - b) * ((1 - x) ** slope) + b
return 2 ** log_freq_out
[docs]
def id3(self, x: float, lf: Optional[List[float]] = None, sl: Optional[float] = None) -> float:
log_freqs = lf or self.log_freqs
slope = sl if sl is not None else self.slope
a = log_freqs[0]
b = log_freqs[1]
log_freq_out = (b - a) * (x ** slope) + a
return 2 ** log_freq_out
[docs]
def id4(self, x: float, lf: Optional[List[float]] = None, sl: Optional[float] = None, da: Optional[List[float]] = None) -> float:
log_freqs = lf or self.log_freqs
slope = sl if sl is not None else self.slope
dur_array = da if da is not None else self.dur_array
if dur_array is None:
dur_array = [1/3,2/3]
bend0 = lambda x: self.id2(x, log_freqs[:2], slope)
bend1 = lambda x: self.id1(x, log_freqs[1:3])
out0 = lambda x: bend0(x / dur_array[0])
out1 = lambda x: bend1((x - dur_array[0]) / dur_array[1])
return out0(x) if x < dur_array[0] else out1(x)
[docs]
def id5(self, x: float, lf: Optional[List[float]] = None, sl: Optional[float] = None, da: Optional[List[float]] = None) -> float:
log_freqs = lf or self.log_freqs
slope = sl if sl is not None else self.slope
dur_array = da if da is not None else self.dur_array
dur_array = dur_array or [1/3,2/3]
bend0 = lambda x: self.id1(x, log_freqs[:2])
bend1 = lambda x: self.id3(x, log_freqs[1:3], slope)
out0 = lambda x: bend0(x / dur_array[0])
out1 = lambda x: bend1((x - dur_array[0]) / dur_array[1])
return out0(x) if x < dur_array[0] else out1(x)
[docs]
def id6(self, x: float, lf: Optional[List[float]] = None, da: Optional[List[float]] = None) -> float:
log_freqs = lf or self.log_freqs
dur_array = da if da is not None else self.dur_array
if dur_array is None:
dur_array = [1/(len(log_freqs)-1)] * (len(log_freqs)-1)
# Get segment start points
starts = get_starts(dur_array)
# Find the correct segment index using proper boundary logic
# This matches the TypeScript findLastIndex behavior
index = -1
for i in range(len(starts)):
if x >= starts[i]:
# Check if this is the last segment or if x is before the next segment start
if i == len(starts) - 1 or x < starts[i + 1]:
index = i
break
if index == -1:
# Fallback for edge cases (x < 0)
index = 0
# Create the interpolation function for this segment
bend = lambda y: self.id1(y, log_freqs[index:index+2])
# Calculate the relative position within this segment
dur_sum = sum(dur_array[:index])
relative_x = (x - dur_sum) / dur_array[index]
# Ensure relative_x is within [0, 1] bounds
relative_x = max(0.0, min(1.0, relative_x))
return bend(relative_x)
[docs]
def id7(self, x: float, lf: Optional[List[float]] = None, da: Optional[List[float]] = None) -> float:
log_freqs = lf or self.log_freqs
dur_array = da if da is not None else self.dur_array
if dur_array is None:
dur_array = [0.5,0.5]
out = log_freqs[0] if x < dur_array[0] else log_freqs[1]
return 2 ** out
[docs]
def id8(self, x: float, lf: Optional[List[float]] = None, da: Optional[List[float]] = None) -> float:
log_freqs = lf or self.log_freqs
dur_array = da if da is not None else self.dur_array
if dur_array is None:
dur_array = [1/3,1/3,1/3]
starts = get_starts(dur_array)
index = 0
for i,s in enumerate(starts):
if x >= s:
index = i
return 2 ** log_freqs[index]
[docs]
def id9(self, x: float, lf: Optional[List[float]] = None, da: Optional[List[float]] = None) -> float:
log_freqs = lf or self.log_freqs
dur_array = da if da is not None else self.dur_array
if dur_array is None:
dur_array = [0.25,0.25,0.25,0.25]
starts = get_starts(dur_array)
index = 0
for i,s in enumerate(starts):
if x >= s:
index = i
return 2 ** log_freqs[index]
[docs]
def id10(self, x: float, lf: Optional[List[float]] = None, da: Optional[List[float]] = None) -> float:
log_freqs = lf or self.log_freqs
dur_array = da if da is not None else self.dur_array
if dur_array is None:
dur_array = [i/6 for i in range(6)]
starts = get_starts(dur_array)
index = 0
for i,s in enumerate(starts):
if x >= s:
index = i
return 2 ** log_freqs[index]
[docs]
def id12(self, x: float) -> float:
return float(self.fund_id12)
[docs]
def id13(self, x: float) -> float:
periods = self.vib_obj['periods']
vert_offset = self.vib_obj['vert_offset']
init_up = self.vib_obj['init_up']
extent = self.vib_obj['extent']
if abs(vert_offset) > extent / 2:
vert_offset = math.copysign(extent/2, vert_offset)
out = math.cos(x * 2 * math.pi * periods + int(init_up) * math.pi)
if x < 1/(2*periods):
start = self.log_freqs[0]
end = math.log2(self.id13(1/(2*periods)))
middle = (end + start)/2
ext = abs(end - start)/2
out = out*ext + middle
return 2 ** out
elif x > 1 - 1/(2*periods):
start = math.log2(self.id13(1 - 1/(2*periods)))
end = self.log_freqs[0]
middle = (end + start)/2
ext = abs(end - start)/2
out = out*ext + middle
return 2 ** out
else:
return 2 ** (out * extent/2 + vert_offset + self.log_freqs[0])
# ---------------- consonant/vowel helpers -----------------------
[docs]
def remove_consonant(self, start: bool = True) -> None:
if start:
self.start_consonant = None
self.start_consonant_hindi = None
self.start_consonant_ipa = None
self.start_consonant_eng_trans = None
art = self.articulations.get('0.00')
if art and art.name == 'consonant':
del self.articulations['0.00']
else:
self.end_consonant = None
self.end_consonant_hindi = None
self.end_consonant_ipa = None
self.end_consonant_eng_trans = None
art = self.articulations.get('1.00')
if art and art.name == 'consonant':
del self.articulations['1.00']
[docs]
def add_consonant(self, consonant: str, start: bool = True) -> None:
idx = self.c_isos.index(consonant) if consonant in self.c_isos else -1
hindi = self.c_hindis[idx] if idx != -1 else None
ipa = self.c_ipas[idx] if idx != -1 else None
eng = self.c_eng_trans[idx] if idx != -1 else None
art = Articulation({'name': 'consonant', 'stroke': consonant, 'hindi': hindi, 'ipa': ipa, 'eng_trans': eng})
if start:
self.start_consonant = consonant
self.start_consonant_hindi = hindi
self.start_consonant_ipa = ipa
self.start_consonant_eng_trans = eng
self.articulations['0.00'] = art
else:
self.end_consonant = consonant
self.end_consonant_hindi = hindi
self.end_consonant_ipa = ipa
self.end_consonant_eng_trans = eng
self.articulations['1.00'] = art
[docs]
def change_consonant(self, consonant: str, start: bool = True) -> None:
idx = self.c_isos.index(consonant) if consonant in self.c_isos else -1
hindi = self.c_hindis[idx] if idx != -1 else None
ipa = self.c_ipas[idx] if idx != -1 else None
eng = self.c_eng_trans[idx] if idx != -1 else None
if start:
self.start_consonant = consonant
self.start_consonant_hindi = hindi
self.start_consonant_ipa = ipa
self.start_consonant_eng_trans = eng
art = self.articulations['0.00']
art.stroke = consonant
art.hindi = hindi
art.ipa = ipa
art.eng_trans = eng
else:
self.end_consonant = consonant
self.end_consonant_hindi = hindi
self.end_consonant_ipa = ipa
self.end_consonant_eng_trans = eng
art = self.articulations['1.00']
art.stroke = consonant
art.hindi = hindi
art.ipa = ipa
art.eng_trans = eng
[docs]
def durations_of_fixed_pitches(self, opts: Optional[Dict] = None) -> Dict:
output_type = 'pitchNumber'
if opts:
output_type = opts.get('output_type', 'pitchNumber')
pitch_durs: Dict = {}
id_str = str(self.id)
if id_str in ('0','13'):
pitch_durs[self.pitches[0].numbered_pitch] = self.dur_tot
elif id_str in ('1','2','3'):
if self.pitches[0].numbered_pitch == self.pitches[1].numbered_pitch:
pitch_durs[self.pitches[0].numbered_pitch] = self.dur_tot
elif id_str in ('4','5'):
p0 = self.pitches[0].numbered_pitch
p1 = self.pitches[1].numbered_pitch
p2 = self.pitches[2].numbered_pitch
if p0 == p1:
pitch_durs[p0] = self.dur_tot * self.dur_array[0]
elif p1 == p2:
pitch_durs[p1] = pitch_durs.get(p1,0) + self.dur_tot * self.dur_array[1]
elif id_str == '6':
last_num = None
for i,p in enumerate(self.pitches):
num = p.numbered_pitch
if num == last_num:
pitch_durs[num] = pitch_durs.get(num,0) + self.dur_tot * self.dur_array[i-1]
last_num = num
elif id_str in ('7','8','9','10','11'):
for i,p in enumerate(self.pitches):
if i < len(self.dur_array) and self.dur_array[i] is not None:
num = p.numbered_pitch
pitch_durs[num] = pitch_durs.get(num,0) + self.dur_tot * self.dur_array[i]
if output_type == 'pitchNumber':
return pitch_durs
elif output_type == 'chroma':
alt = {}
for p,v in pitch_durs.items():
c = Pitch.pitch_number_to_chroma(int(p))
alt[c] = v
return alt
elif output_type == 'scaleDegree':
alt = {}
for p,v in pitch_durs.items():
c = Pitch.pitch_number_to_chroma(int(p))
sd = Pitch.chroma_to_scale_degree(c)[0]
alt[sd] = v
return alt
elif output_type == 'sargamLetter':
alt = {}
for p,v in pitch_durs.items():
s = Pitch.from_pitch_number(int(p)).sargam_letter
alt[s] = v
return alt
else:
raise Exception('outputType not recognized')
[docs]
def convert_c_iso_to_hindi_and_ipa(self) -> None:
for art in self.articulations.values():
if art.name == 'consonant':
if not isinstance(art.stroke, str):
raise Exception('stroke is not a string')
c_iso = art.stroke
if c_iso in self.c_isos:
idx = self.c_isos.index(c_iso)
art.hindi = getattr(art, 'hindi', None) or self.c_hindis[idx]
art.ipa = getattr(art, 'ipa', None) or self.c_ipas[idx]
art.eng_trans = getattr(art, 'eng_trans', None) or self.c_eng_trans[idx]
else:
if not hasattr(art, 'hindi'):
art.hindi = None
if not hasattr(art, 'ipa'):
art.ipa = None
if not hasattr(art, 'eng_trans'):
art.eng_trans = None
if self.start_consonant is not None:
c_iso = self.start_consonant
if c_iso in self.c_isos:
idx = self.c_isos.index(c_iso)
self.start_consonant_hindi = getattr(self, 'start_consonant_hindi', None) or self.c_hindis[idx]
self.start_consonant_ipa = getattr(self, 'start_consonant_ipa', None) or self.c_ipas[idx]
self.start_consonant_eng_trans = getattr(self, 'start_consonant_eng_trans', None) or self.c_eng_trans[idx]
if self.end_consonant is not None:
c_iso = self.end_consonant
if c_iso in self.c_isos:
idx = self.c_isos.index(c_iso)
self.end_consonant_hindi = getattr(self, 'end_consonant_hindi', None) or self.c_hindis[idx]
self.end_consonant_ipa = getattr(self, 'end_consonant_ipa', None) or self.c_ipas[idx]
self.end_consonant_eng_trans = getattr(self, 'end_consonant_eng_trans', None) or self.c_eng_trans[idx]
if self.vowel is not None:
v_iso = self.vowel
if v_iso in self.v_isos:
idx = self.v_isos.index(v_iso)
self.vowel_hindi = getattr(self, 'vowel_hindi', None) or self.v_hindis[idx]
self.vowel_ipa = getattr(self, 'vowel_ipa', None) or self.v_ipas[idx]
self.vowel_eng_trans = getattr(self, 'vowel_eng_trans', None) or self.v_eng_trans[idx]
[docs]
def update_vowel(self, v_iso: str) -> None:
if v_iso in self.v_isos:
idx = self.v_isos.index(v_iso)
self.vowel = v_iso
self.vowel_hindi = self.v_hindis[idx]
self.vowel_ipa = self.v_ipas[idx]
self.vowel_eng_trans = self.v_eng_trans[idx]
else:
self.vowel = v_iso
self.vowel_hindi = None
self.vowel_ipa = None
self.vowel_eng_trans = None
[docs]
def to_json(self) -> Dict:
data = {
'id': self.id,
'pitches': [p.to_json() for p in self.pitches],
'durTot': self.dur_tot,
'durArray': self.dur_array,
'slope': self.slope,
'articulations': {k: a.to_json() for k, a in self.articulations.items()},
'startTime': self.start_time,
'num': self.num,
'fundID12': self.fund_id12,
'vibObj': self.vib_obj,
'vowel': self.vowel,
'startConsonant': self.start_consonant,
'startConsonantHindi': self.start_consonant_hindi,
'startConsonantIpa': self.start_consonant_ipa,
'startConsonantEngTrans': self.start_consonant_eng_trans,
'endConsonant': self.end_consonant,
'endConsonantHindi': self.end_consonant_hindi,
'endConsonantIpa': self.end_consonant_ipa,
'endConsonantEngTrans': self.end_consonant_eng_trans,
'groupId': self.group_id,
'automation': self.automation.to_json() if self.automation else None,
'uniqueId': self.unique_id,
}
# drop None values so they serialize as undefined (omitted) rather than null
return {k: v for k, v in data.items() if v is not None}
[docs]
@staticmethod
def from_json(obj: Dict, ratios=None, fundamental=None) -> 'Trajectory':
opts = humps.decamelize(obj)
pitches = [Pitch.from_json(p, ratios=ratios, fundamental=fundamental)
for p in opts.get('pitches', [])]
arts = {}
for k,v in opts.get('articulations', {}).items():
if v is not None:
arts[k] = Articulation.from_json(v)
automation = opts.get('automation')
instr = opts.get('instrumentation')
if isinstance(instr, str):
try:
opts['instrumentation'] = Instrument(instr)
except ValueError:
opts['instrumentation'] = Instrument.Sitar
opts['pitches'] = pitches
opts['articulations'] = arts
opts['automation'] = automation
return Trajectory(opts)
[docs]
@staticmethod
def names() -> List[str]:
return Trajectory().names