Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.

Commit 6d97cf7

Browse files
jon-myersclaude
andauthored
feat: sync Python API with TypeScript codebase (issue #61) (#62)
- Derive chikari pitches from raga rule set (4 pitches: Sa, Sa, Pa, Ga) - Chikari.to_json() no longer serializes pitches (derived at runtime) - Piece.chikari_freqs() returns 4 raga-aware frequencies - section_starts_grid is now a computed property from phrase.is_section_start - Add string_idx param to all_trajectories() and traj_start_times() - Add ensure_string_synchronization() and string_from_traj() for dual-string - Add Sarangi to possible_trajs mapping - Reconcile query_types.py SecCatType keys with database format - Fix auth_logout_test for environments without keyring installed Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bee2383 commit 6d97cf7

8 files changed

Lines changed: 471 additions & 107 deletions

File tree

idtap/classes/chikari.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,15 @@ def _validate_parameters(self, opts: dict) -> None:
9292
def to_json(self) -> Dict:
9393
return {
9494
'fundamental': self.fundamental,
95-
'pitches': [p.to_json() for p in self.pitches],
9695
'uniqueId': self.unique_id,
9796
}
9897

9998
@staticmethod
10099
def from_json(obj: Dict) -> 'Chikari':
101100
opts = humps.decamelize(obj)
102-
pitches = [Pitch.from_json(p) for p in opts.get('pitches', [])]
103-
opts['pitches'] = pitches
101+
# Handle old format (with pitches) for backward compatibility
102+
pitches_data = opts.get('pitches')
103+
if pitches_data:
104+
pitches = [Pitch.from_json(p) for p in pitches_data]
105+
opts['pitches'] = pitches
104106
return Chikari(opts) # type: ignore[arg-type]

idtap/classes/piece.py

Lines changed: 108 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ def __init__(self, options: Optional[dict] = None) -> None:
120120
Instrument.Sitar: list(range(14)),
121121
Instrument.Vocal_M: [0, 1, 2, 3, 4, 5, 6, 12, 13],
122122
Instrument.Vocal_F: [0, 1, 2, 3, 4, 5, 6, 12, 13],
123+
Instrument.Sarangi: list(range(14)),
123124
}
124125

125126
first_inst = self.instrumentation[0]
@@ -169,29 +170,33 @@ def __init__(self, options: Optional[dict] = None) -> None:
169170
else:
170171
self.meters.append(Meter.from_json(m))
171172

173+
# Parse section starts into a local variable, then apply to phrases
172174
ss_grid = opts.get("sectionStartsGrid")
173175
if ss_grid is None:
174176
ss = opts.get("sectionStarts", [0])
175177
ss_grid = [ss]
176178
for _ in range(len(ss_grid), len(self.instrumentation)):
177179
ss_grid.append([0])
178-
self.section_starts_grid: List[List[float]] = [sorted(list(s)) for s in ss_grid]
180+
ss_grid = [sorted(list(s)) for s in ss_grid]
179181

180-
# Migrate old sectionStartsGrid to phrase-level is_section_start properties
181-
# This enables phrase-based section tracking while maintaining backward compatibility
182-
if self.section_starts_grid and self.phrase_grid:
182+
# Apply section starts to phrase-level is_section_start flags
183+
if ss_grid and self.phrase_grid:
183184
for inst_idx, phrases in enumerate(self.phrase_grid):
184-
if inst_idx < len(self.section_starts_grid):
185-
starts = self.section_starts_grid[inst_idx]
185+
if inst_idx < len(ss_grid):
186+
starts = ss_grid[inst_idx]
186187
for phrase_idx, phrase in enumerate(phrases):
187-
# Convert indices to integers for comparison
188-
phrase.is_section_start = phrase_idx in [int(s) for s in starts]
188+
if phrase.is_section_start is None:
189+
phrase.is_section_start = phrase_idx in [int(s) for s in starts]
190+
# Ensure every phrase has a boolean is_section_start
191+
for phrase in phrases:
192+
if phrase.is_section_start is None:
193+
phrase.is_section_start = False
189194

190195
sc_grid = opts.get("sectionCatGrid")
191196
if sc_grid is None:
192197
section_cat = opts.get("sectionCategorization")
193198
sc_grid = []
194-
for i, ss in enumerate(self.section_starts_grid):
199+
for i, ss in enumerate(ss_grid):
195200
if i == 0:
196201
if section_cat is not None:
197202
for c in section_cat:
@@ -203,7 +208,7 @@ def __init__(self, options: Optional[dict] = None) -> None:
203208
row = [init_sec_categorization() for _ in ss]
204209
sc_grid.append(row)
205210
self.section_cat_grid: List[List[SecCatType]] = sc_grid
206-
for i, ss in enumerate(self.section_starts_grid):
211+
for i, ss in enumerate(ss_grid):
207212
while len(self.section_cat_grid) <= i:
208213
self.section_cat_grid.append([init_sec_categorization() for _ in ss])
209214
if len(self.section_cat_grid[i]) < len(ss):
@@ -219,7 +224,7 @@ def __init__(self, options: Optional[dict] = None) -> None:
219224
[ [f for f in fields if f != ""] for fields in track ]
220225
for track in ad_hoc
221226
]
222-
while len(self.ad_hoc_section_cat_grid) < len(self.section_starts_grid):
227+
while len(self.ad_hoc_section_cat_grid) < len(ss_grid):
223228
self.ad_hoc_section_cat_grid.append([[] for _ in self.ad_hoc_section_cat_grid[0]])
224229

225230
self.excerpt_range = opts.get("excerptRange")
@@ -464,12 +469,26 @@ def dur_array(self, arr: List[float]) -> None:
464469
self.dur_array_grid[0] = arr
465470

466471
@property
467-
def section_starts(self) -> List[float]:
472+
def section_starts_grid(self) -> List[List[int]]:
473+
"""Compute section starts from phrase-level is_section_start flags."""
474+
return [[idx for idx, p in enumerate(phrases) if p.is_section_start]
475+
for phrases in self.phrase_grid]
476+
477+
@section_starts_grid.setter
478+
def section_starts_grid(self, value: List[List[int]]) -> None:
479+
"""Apply section starts to phrase-level is_section_start flags."""
480+
for inst_idx, starts in enumerate(value):
481+
if inst_idx < len(self.phrase_grid):
482+
for p_idx, phrase in enumerate(self.phrase_grid[inst_idx]):
483+
phrase.is_section_start = p_idx in [int(s) for s in starts]
484+
485+
@property
486+
def section_starts(self) -> List[int]:
468487
return self.section_starts_grid[0]
469488

470489
@section_starts.setter
471-
def section_starts(self, arr: List[float]) -> None:
472-
self.section_starts_grid[0] = arr
490+
def section_starts(self, arr: List[int]) -> None:
491+
self.section_starts_grid = [arr] + self.section_starts_grid[1:]
473492

474493
@property
475494
def section_categorization(self) -> List[SecCatType]:
@@ -863,10 +882,18 @@ def remove_meter(self, meter: Meter) -> None:
863882
self.meters.remove(meter)
864883

865884
# ------------------------------------------------------------------
866-
def all_trajectories(self, inst: int = 0) -> List[Trajectory]:
885+
def all_trajectories(self, inst: int = 0, string_idx: int = 0) -> List[Trajectory]:
886+
"""Get all trajectories for a given instrument track and string index.
887+
888+
Args:
889+
inst: Instrument track index (default 0).
890+
string_idx: String index within the instrument (default 0).
891+
For Sitar/Sarangi, string 0 is main, string 1 is jor/second.
892+
"""
867893
trajs: List[Trajectory] = []
868894
for p in self.phrase_grid[inst]:
869-
trajs.extend(p.trajectories)
895+
if string_idx < len(p.trajectory_grid):
896+
trajs.extend(p.trajectory_grid[string_idx])
870897
return trajs
871898

872899
# ------------------------------------------------------------------
@@ -896,6 +923,45 @@ def track_from_phrase_uid(self, uid: str) -> int:
896923
return i
897924
raise ValueError("Phrase not found")
898925

926+
def string_from_traj(self, traj: Trajectory) -> int:
927+
"""Determine which string index contains a given trajectory.
928+
929+
Searches all phrases across all strings by unique_id.
930+
Returns the string index (0 or 1). Raises ValueError if not found.
931+
"""
932+
for phrases in self.phrase_grid:
933+
for phrase in phrases:
934+
for string_idx, string_trajs in enumerate(phrase.trajectory_grid):
935+
for t in string_trajs:
936+
if t.unique_id == traj.unique_id:
937+
return string_idx
938+
raise ValueError("Trajectory not found in any string")
939+
940+
def ensure_string_synchronization(self) -> None:
941+
"""For Sitar/Sarangi, ensure trajectory_grid[1] exists and is synchronized.
942+
943+
If string 1 is empty or contains only silent trajectories (id=12),
944+
fill it with a single silent trajectory matching the phrase duration.
945+
"""
946+
polyphonic_instruments = {Instrument.Sitar, Instrument.Sarangi}
947+
for inst_idx, inst in enumerate(self.instrumentation):
948+
if inst not in polyphonic_instruments:
949+
continue
950+
for phrase in self.phrase_grid[inst_idx]:
951+
# Ensure trajectory_grid has at least 2 entries
952+
while len(phrase.trajectory_grid) < 2:
953+
phrase.trajectory_grid.append([])
954+
955+
string_1 = phrase.trajectory_grid[1]
956+
is_empty_or_silent = (
957+
len(string_1) == 0 or
958+
all(t.id == 12 for t in string_1)
959+
)
960+
if is_empty_or_silent:
961+
silent = Trajectory({'id': 12, 'dur_tot': phrase.dur_tot})
962+
phrase.trajectory_grid[1] = [silent]
963+
phrase.reset()
964+
899965
def traj_from_uid(self, uid: str, track: int = 0) -> Trajectory:
900966
for t in self.all_trajectories(track):
901967
if t.unique_id == uid:
@@ -980,14 +1046,12 @@ def proportions_of_fixed_pitches(
9801046
return durations_of_fixed_pitches(trajs=self.all_trajectories(inst), output_type=output_type, count_type="proportional")
9811047

9821048
# ------------------------------------------------------------------
983-
def chikari_freqs(self, inst_idx: int) -> List[float]:
984-
phrases = self.phrase_grid[inst_idx]
985-
for p in phrases:
986-
if p.chikaris:
987-
chikari = list(p.chikaris.values())[0]
988-
return [c.frequency for c in chikari.pitches[:2]]
989-
f = self.raga.fundamental
990-
return [f * 2, f * 4]
1049+
def chikari_freqs(self, inst_idx: int = 0) -> List[float]:
1050+
"""Return 4 chikari frequencies derived from the raga.
1051+
1052+
Returns 0.0 for strings that are silent (None pitch).
1053+
"""
1054+
return [p.frequency if p is not None else 0.0 for p in self.raga.chikari_pitches]
9911055

9921056
# ------------------------------------------------------------------
9931057
def dur_starts(self, track: int = 0) -> List[float]:
@@ -997,12 +1061,26 @@ def dur_starts(self, track: int = 0) -> List[float]:
9971061
raise Exception("durTot is undefined")
9981062
return get_starts([d * self.dur_tot for d in self.dur_array_grid[track]])
9991063

1000-
def traj_start_times(self, inst: int = 0) -> List[float]:
1001-
trajs = self.all_trajectories(inst)
1002-
times = [0.0]
1003-
for t in trajs[:-1]:
1004-
times.append(times[-1] + t.dur_tot)
1005-
return times
1064+
def traj_start_times(self, inst: int = 0, string_idx: int = 0) -> List[float]:
1065+
"""Get start times for all trajectories in a given string.
1066+
1067+
For string 0: cumulative duration (standard sequential timing).
1068+
For string > 0: phrase-boundary based (phrase.start_time + traj.start_time).
1069+
"""
1070+
if string_idx == 0:
1071+
trajs = self.all_trajectories(inst, 0)
1072+
times = [0.0]
1073+
for t in trajs[:-1]:
1074+
times.append(times[-1] + t.dur_tot)
1075+
return times
1076+
else:
1077+
times: List[float] = []
1078+
for p in self.phrase_grid[inst]:
1079+
phrase_start = p.start_time or 0.0
1080+
if string_idx < len(p.trajectory_grid):
1081+
for traj in p.trajectory_grid[string_idx]:
1082+
times.append(phrase_start + (traj.start_time or 0.0))
1083+
return times
10061084

10071085
def all_pitches(self, repetition: bool = True, pitch_number: bool = False, track: int = 0) -> List[Any]:
10081086
pitches: List[Any] = []
@@ -1358,7 +1436,6 @@ def to_json(self) -> Dict[str, Any]:
13581436
"name": self.name,
13591437
"family_name": self.family_name,
13601438
"given_name": self.given_name,
1361-
"sectionStartsGrid": self.section_starts_grid,
13621439
"sectionCatGrid": self.section_cat_grid,
13631440
"explicitPermissions": self.explicit_permissions,
13641441
"soloist": self.soloist,

idtap/classes/raga.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -506,11 +506,35 @@ def stratified_ratios(self) -> List[Union[float, List[float]]]:
506506
return ratios
507507

508508
@property
509-
def chikari_pitches(self) -> List[Pitch]:
510-
return [
511-
Pitch({'swara': 's', 'oct': 2, 'fundamental': self.fundamental}),
512-
Pitch({'swara': 's', 'oct': 1, 'fundamental': self.fundamental}),
513-
]
509+
def chikari_pitches(self) -> List[Optional[Pitch]]:
510+
"""Derive 4 chikari pitches from the raga rule set.
511+
512+
Returns list of 4 pitches (or None for silent strings):
513+
[0] Sa oct 2 (always present)
514+
[1] Sa oct 1 (always present)
515+
[2] Pa oct 1 (present if Pa is in the raga, else None)
516+
[3] Ga oct 1 (present if exactly one Ga variant, else None)
517+
"""
518+
ratios = self.stratified_ratios
519+
520+
sa_high = Pitch({'swara': 'sa', 'oct': 2, 'fundamental': self.fundamental, 'ratios': ratios})
521+
sa_low = Pitch({'swara': 'sa', 'oct': 1, 'fundamental': self.fundamental, 'ratios': ratios})
522+
523+
pa_pitch: Optional[Pitch] = None
524+
if self.rule_set.get('pa') is True:
525+
pa_pitch = Pitch({'swara': 'pa', 'oct': 1, 'fundamental': self.fundamental, 'ratios': ratios})
526+
527+
ga_pitch: Optional[Pitch] = None
528+
ga_rule = self.rule_set.get('ga')
529+
if isinstance(ga_rule, dict):
530+
has_lowered = ga_rule.get('lowered', False)
531+
has_raised = ga_rule.get('raised', False)
532+
if has_lowered and not has_raised:
533+
ga_pitch = Pitch({'swara': 'ga', 'oct': 1, 'raised': False, 'fundamental': self.fundamental, 'ratios': ratios})
534+
elif has_raised and not has_lowered:
535+
ga_pitch = Pitch({'swara': 'ga', 'oct': 1, 'raised': True, 'fundamental': self.fundamental, 'ratios': ratios})
536+
537+
return [sa_high, sa_low, pa_pitch, ga_pitch]
514538

515539
def get_frequencies(self, low: float = 100, high: float = 800) -> List[float]:
516540
freqs: List[float] = []

idtap/query_types.py

Lines changed: 14 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -53,37 +53,8 @@ class SegmentationType(str, Enum):
5353
CONNECTED_SEQUENCE_OF_TRAJECTORIES = "connectedSequenceOfTrajectories"
5454

5555

56-
# Section categorization types (matching TypeScript SecCatType)
57-
class SecCatType(TypedDict, total=False):
58-
"""Section categorization structure."""
59-
# Pre-Chiz Alap section
60-
pre_chiz_alap: Dict[str, bool]
61-
62-
# Alap section types
63-
alap: Dict[str, bool]
64-
65-
# Composition types
66-
composition_type: Dict[str, bool]
67-
68-
# Tempo/section types
69-
comp_section_tempo: Dict[str, bool]
70-
71-
# Tala types
72-
tala: Dict[str, bool]
73-
74-
# Other categories
75-
improvisation: Dict[str, bool]
76-
other: Dict[str, bool]
77-
78-
# Top level category
79-
top_level: Literal[
80-
"Pre-Chiz Alap",
81-
"Alap",
82-
"Composition",
83-
"Improvisation",
84-
"Other",
85-
"None"
86-
]
56+
# Section categorization type alias (matching piece.py and database format)
57+
SecCatType = Dict[str, Union[Dict[str, bool], str]]
8758

8859

8960
# Phrase categorization types (matching TypeScript PhraseCatType)
@@ -187,15 +158,14 @@ class MultipleOptionType(TypedDict, total=False):
187158

188159
# Default categorization structures
189160
def init_sec_categorization() -> SecCatType:
190-
"""Initialize default section categorization structure."""
161+
"""Initialize default section categorization structure.
162+
163+
Keys use display-string format matching the database and piece.py.
164+
"""
191165
return {
192-
"pre_chiz_alap": {"Pre-Chiz Alap": False},
193-
"alap": {
194-
"Alap": False,
195-
"Jor": False,
196-
"Alap-Jhala": False
197-
},
198-
"composition_type": {
166+
"Pre-Chiz Alap": {"Pre-Chiz Alap": False},
167+
"Alap": {"Alap": False, "Jor": False, "Alap-Jhala": False},
168+
"Composition Type": {
199169
"Dhrupad": False,
200170
"Bandish": False,
201171
"Thumri": False,
@@ -210,22 +180,18 @@ def init_sec_categorization() -> SecCatType:
210180
"Razakhani Gat": False,
211181
"Ferozkhani Gat": False,
212182
},
213-
"comp_section_tempo": {
183+
"Comp.-section/Tempo": {
214184
"Ati Vilambit": False,
215185
"Vilambit": False,
216186
"Madhya": False,
217187
"Drut": False,
218188
"Ati Drut": False,
219189
"Jhala": False,
220190
},
221-
"tala": {
222-
"Ektal": False,
223-
"Tintal": False,
224-
"Rupak": False
225-
},
226-
"improvisation": {"Improvisation": False},
227-
"other": {"Other": False},
228-
"top_level": "None"
191+
"Tala": {"Ektal": False, "Tintal": False, "Rupak": False},
192+
"Improvisation": {"Improvisation": False},
193+
"Other": {"Other": False},
194+
"Top Level": "None",
229195
}
230196

231197

0 commit comments

Comments
 (0)