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

Commit 488505d

Browse files
jon-myersclaude
andcommitted
feat: sync serialization with TypeScript — strip redundant fields, thread raga context
Strip redundant/duplicate fields from to_json() to match TypeScript PR #887: - Pitch: remove ratios and fundamental (inherited from raga) - Trajectory: remove name, instrumentation, and tags (derived/defaulted) - Phrase: remove raga (redundant with piece-level raga) Thread ratios/fundamental from piece-level Raga down through the deserialization chain (Piece → Phrase → Trajectory → Pitch) with fallback to embedded values for backward compatibility with old data. Also adds clone_transcription() and delete_transcription() to SwaraClient, fixes Group/Phrase to_json() serialization bugs, and hardens Piece.from_json() against unknown server fields. Verified 68.3% size reduction on a 6.9MB transcription with all 6,770 pitch frequencies preserved exactly through full clone/save/reload cycle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9940060 commit 488505d

8 files changed

Lines changed: 545 additions & 26 deletions

File tree

idtap/classes/group.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,12 @@ def add_traj(self, traj: Trajectory) -> None:
106106

107107
def to_json(self) -> Dict:
108108
return {
109-
'trajectories': self.trajectories,
109+
'trajectories': [t.to_json() if hasattr(t, 'to_json') else t for t in self.trajectories],
110110
'id': self.id,
111111
}
112112

113113
@staticmethod
114114
def from_json(obj: Dict) -> 'Group':
115-
trajs = obj.get('trajectories', [])
115+
trajs = [t if isinstance(t, Trajectory) else Trajectory.from_json(t)
116+
for t in obj.get('trajectories', [])]
116117
return Group({'trajectories': trajs, 'id': obj.get('id')})

idtap/classes/phrase.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -359,11 +359,13 @@ def realign_pitches(self) -> None:
359359
if not self.raga:
360360
return
361361
ratios = self.raga.stratified_ratios
362+
fundamental = self.raga.fundamental
362363
for traj in self.trajectories:
363364
new_pitches = []
364365
for p in traj.pitches:
365366
opts = p.to_json()
366367
opts['ratios'] = ratios
368+
opts['fundamental'] = fundamental
367369
new_pitches.append(Pitch(opts))
368370
traj.pitches = new_pitches
369371

@@ -545,29 +547,38 @@ def to_json(self) -> Dict[str, Any]:
545547
'durTot': self.dur_tot,
546548
'durArray': self.dur_array,
547549
'chikaris': {k: c.to_json() for k, c in self.chikaris.items()},
548-
'raga': self.raga.to_json() if self.raga else None,
549550
'startTime': self.start_time,
550551
'trajectoryGrid': [[t.to_json() for t in row] for row in self.trajectory_grid],
551-
'instrumentation': self.instrumentation,
552-
'groupsGrid': self.groups_grid,
552+
'instrumentation': [i.value if hasattr(i, 'value') else i for i in self.instrumentation],
553+
'groupsGrid': [[g.to_json() for g in row] for row in self.groups_grid],
553554
'categorizationGrid': self.categorization_grid,
554555
'uniqueId': self.unique_id,
555556
'adHocCategorizationGrid': self.ad_hoc_categorization_grid,
556557
'isSectionStart': self.is_section_start,
557558
}
558559

559560
@staticmethod
560-
def from_json(obj: Dict[str, Any]) -> 'Phrase':
561+
def from_json(obj: Dict[str, Any], ratios=None, fundamental=None) -> 'Phrase':
561562
opts = selective_decamelize(obj)
563+
564+
# If phrase has its own raga (legacy data), use it as fallback context
565+
phrase_raga = opts.get('raga')
566+
if phrase_raga is not None and not isinstance(phrase_raga, Raga):
567+
phrase_raga = Raga.from_json(phrase_raga)
568+
opts['raga'] = phrase_raga
569+
570+
r = ratios if ratios is not None else (phrase_raga.stratified_ratios if phrase_raga else None)
571+
f = fundamental if fundamental is not None else (phrase_raga.fundamental if phrase_raga else None)
572+
562573
trajectory_grid = opts.get('trajectory_grid')
563574
if trajectory_grid is not None:
564575
tg = []
565576
for row in trajectory_grid:
566-
tg.append([Trajectory.from_json(t) for t in row])
577+
tg.append([Trajectory.from_json(t, ratios=r, fundamental=f) for t in row])
567578
opts['trajectory_grid'] = tg
568579
trajectories = opts.get('trajectories')
569580
if trajectories is not None:
570-
opts['trajectories'] = [Trajectory.from_json(t) for t in trajectories]
581+
opts['trajectories'] = [Trajectory.from_json(t, ratios=r, fundamental=f) for t in trajectories]
571582
chikaris = opts.get('chikaris')
572583
if chikaris is not None:
573584
new_c = {}
@@ -583,9 +594,6 @@ def from_json(obj: Dict[str, Any]) -> 'Phrase':
583594
new_obj[str(k)] = Chikari.from_json(v)
584595
new_grid.append(new_obj)
585596
opts['chikari_grid'] = new_grid
586-
raga = opts.get('raga')
587-
if raga is not None and not isinstance(raga, Raga):
588-
opts['raga'] = Raga.from_json(raga)
589597
return Phrase(opts)
590598

591599
def to_note_view_phrase(self) -> 'NoteViewPhrase':

idtap/classes/piece.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,8 +371,8 @@ def _validate_parameter_types(self, opts: dict) -> None:
371371
if 'collections' in opts and opts['collections'] is not None:
372372
if not isinstance(opts['collections'], list):
373373
raise TypeError(f"Parameter 'collections' must be a list, got {type(opts['collections']).__name__}")
374-
if not all(isinstance(item, str) for item in opts['collections']):
375-
raise TypeError("All items in 'collections' must be strings")
374+
if not all(isinstance(item, str) or item is None for item in opts['collections']):
375+
raise TypeError("All items in 'collections' must be strings or None")
376376

377377
# Validate trackTitles
378378
if 'trackTitles' in opts and opts['trackTitles'] is not None:
@@ -1451,12 +1451,19 @@ def to_json(self) -> Dict[str, Any]:
14511451
@staticmethod
14521452
def from_json(obj: Dict[str, Any]) -> "Piece":
14531453
new_obj = dict(obj)
1454+
raga = None
14541455
if "raga" in new_obj:
1455-
new_obj["raga"] = Raga.from_json(new_obj["raga"])
1456+
raga = Raga.from_json(new_obj["raga"])
1457+
new_obj["raga"] = raga
1458+
1459+
# Extract raga context for threading down to pitches
1460+
ratios = raga.stratified_ratios if raga else None
1461+
fundamental = raga.fundamental if raga else None
1462+
14561463
if "phraseGrid" in new_obj:
14571464
pg = []
14581465
for row in new_obj["phraseGrid"]:
1459-
phrase_row = [Phrase.from_json(p) for p in row]
1466+
phrase_row = [Phrase.from_json(p, ratios=ratios, fundamental=fundamental) for p in row]
14601467
pg.append(phrase_row)
14611468
# reconstruct groups so they reference existing trajectories
14621469
for row in pg:
@@ -1465,6 +1472,8 @@ def from_json(obj: Dict[str, Any]) -> "Piece":
14651472
for g_list in phrase.groups_grid:
14661473
rebuilt: List[Group] = []
14671474
for g in g_list:
1475+
if isinstance(g, str):
1476+
continue # skip bare ID strings
14681477
data = g if isinstance(g, dict) else g.to_json()
14691478
trajs = []
14701479
for t in data.get("trajectories", []):
@@ -1494,6 +1503,20 @@ def from_json(obj: Dict[str, Any]) -> "Piece":
14941503
dm = dm["$date"]
14951504
new_obj["dateModified"] = datetime.fromisoformat(str(dm).replace('Z',''))
14961505

1506+
# Strip keys not recognized by the constructor (e.g. server-only
1507+
# fields like 'userId' that are not part of the data model).
1508+
allowed = {
1509+
'raga', 'instrumentation', 'phraseGrid', 'phrases', 'title',
1510+
'dateCreated', 'dateModified', 'location', '_id', 'audioID',
1511+
'audio_DB_ID', 'userID', 'name', 'family_name', 'given_name',
1512+
'permissions', 'soloist', 'soloInstrument', 'explicitPermissions',
1513+
'meters', 'sectionStartsGrid', 'sectionStarts', 'sectionCatGrid',
1514+
'sectionCategorization', 'adHocSectionCatGrid', 'excerptRange',
1515+
'assemblageDescriptors', 'collections', 'durTot', 'durArrayGrid',
1516+
'durArray', 'trackTitles',
1517+
}
1518+
new_obj = {k: v for k, v in new_obj.items() if k in allowed}
1519+
14971520
piece = Piece(new_obj)
14981521

14991522
# reconnect groups to actual trajectories

idtap/classes/pitch.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,6 @@ def to_json(self):
203203
'swara': self.swara,
204204
'raised': self.raised,
205205
'oct': self.oct,
206-
'ratios': self.ratios,
207-
'fundamental': self.fundamental,
208206
'logOffset': self.log_offset,
209207
}
210208

@@ -391,5 +389,10 @@ def same_as(self, other: "Pitch") -> bool:
391389
return self.swara == other.swara and self.oct == other.oct and self.raised == other.raised
392390

393391
@classmethod
394-
def from_json(cls, obj: dict) -> "Pitch":
395-
return cls(obj)
392+
def from_json(cls, obj: dict, ratios=None, fundamental=None) -> "Pitch":
393+
opts = dict(obj)
394+
if ratios is not None:
395+
opts['ratios'] = ratios
396+
if fundamental is not None:
397+
opts['fundamental'] = fundamental
398+
return cls(opts)

idtap/classes/trajectory.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -911,10 +911,8 @@ def to_json(self) -> Dict:
911911
'articulations': {k: a.to_json() for k, a in self.articulations.items()},
912912
'startTime': self.start_time,
913913
'num': self.num,
914-
'name': self.name,
915914
'fundID12': self.fund_id12,
916915
'vibObj': self.vib_obj,
917-
'instrumentation': self.instrumentation.value if isinstance(self.instrumentation, Instrument) else self.instrumentation,
918916
'vowel': self.vowel,
919917
'startConsonant': self.start_consonant,
920918
'startConsonantHindi': self.start_consonant_hindi,
@@ -927,15 +925,15 @@ def to_json(self) -> Dict:
927925
'groupId': self.group_id,
928926
'automation': self.automation.to_json() if self.automation else None,
929927
'uniqueId': self.unique_id,
930-
'tags': self.tags,
931928
}
932929
# drop None values so they serialize as undefined (omitted) rather than null
933930
return {k: v for k, v in data.items() if v is not None}
934931

935932
@staticmethod
936-
def from_json(obj: Dict) -> 'Trajectory':
933+
def from_json(obj: Dict, ratios=None, fundamental=None) -> 'Trajectory':
937934
opts = humps.decamelize(obj)
938-
pitches = [Pitch.from_json(p) for p in opts.get('pitches', [])]
935+
pitches = [Pitch.from_json(p, ratios=ratios, fundamental=fundamental)
936+
for p in opts.get('pitches', [])]
939937
arts = {}
940938
for k,v in opts.get('articulations', {}).items():
941939
if v is not None:

idtap/client.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,15 @@ def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any:
126126
return response.json()
127127
return response.content
128128

129+
def _delete_json(self, endpoint: str, payload: Dict[str, Any]) -> Any:
130+
url = self.base_url + endpoint
131+
headers = self._auth_headers()
132+
response = requests.delete(url, json=payload, headers=headers, timeout=1800)
133+
response.raise_for_status()
134+
if response.content:
135+
return response.json()
136+
return None
137+
129138
# ---- API methods ----
130139
def get_piece(self, piece_id: str, fetch_rule_set: bool = True) -> Any:
131140
"""Return transcription JSON for the given id.
@@ -182,6 +191,76 @@ def insert_new_transcription(self, piece: Dict[str, Any]) -> Any:
182191
payload["userID"] = self.user_id
183192
return self._post_json("insertNewTranscription", payload)
184193

194+
def clone_transcription(
195+
self,
196+
piece_id: str,
197+
title: Optional[str] = None,
198+
explicit_permissions: Optional[Dict[str, Any]] = None,
199+
soloist: Optional[str] = None,
200+
solo_instrument: Optional[str] = None,
201+
) -> Any:
202+
"""Clone a transcription, creating a new copy owned by the current user.
203+
204+
The server copies all transcription data (phrases, trajectories, pitches,
205+
raga, audio association, etc.) and assigns a new ID, owner, and timestamps.
206+
207+
Args:
208+
piece_id: The ID of the transcription to clone.
209+
title: Title for the cloned transcription. Defaults to server behavior.
210+
explicit_permissions: Permission object with 'edit', 'view' (user ID
211+
lists) and 'publicView' (bool). Defaults to private.
212+
soloist: Soloist name for the clone.
213+
solo_instrument: Solo instrument for the clone.
214+
215+
Returns:
216+
Server response with ``insertedId`` of the new transcription.
217+
"""
218+
if not self.user_id:
219+
raise RuntimeError("Not authenticated: cannot clone transcription")
220+
payload: Dict[str, Any] = {
221+
"id": piece_id,
222+
"newOwner": self.user_id,
223+
}
224+
if title is not None:
225+
payload["title"] = title
226+
if self.user:
227+
payload["name"] = self.user.get("name", "")
228+
payload["family_name"] = self.user.get("family_name", "")
229+
payload["given_name"] = self.user.get("given_name", "")
230+
if explicit_permissions is not None:
231+
payload["explicitPermissions"] = explicit_permissions
232+
else:
233+
payload["explicitPermissions"] = {
234+
"edit": [],
235+
"view": [],
236+
"publicView": False,
237+
}
238+
if soloist is not None:
239+
payload["soloist"] = soloist
240+
if solo_instrument is not None:
241+
payload["soloInstrument"] = solo_instrument
242+
return self._post_json("cloneTranscription", payload)
243+
244+
def delete_transcription(self, piece_id: str) -> Any:
245+
"""Delete a transcription from the server.
246+
247+
Removes the transcription document and the reference from the user's
248+
transcriptions array.
249+
250+
Args:
251+
piece_id: The ID of the transcription to delete.
252+
253+
Returns:
254+
Server response with ``deletedCount``.
255+
"""
256+
if not self.user_id:
257+
raise RuntimeError("Not authenticated: cannot delete transcription")
258+
payload = {
259+
"_id": piece_id,
260+
"userID": self.user_id,
261+
}
262+
return self._delete_json("oneTranscription", payload)
263+
185264
def _prompt_for_waiver_if_needed(self) -> None:
186265
"""Interactively prompt user to agree to waiver if not already agreed."""
187266
if self.has_agreed_to_waiver():

idtap/tests/pitch_test.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ def test_default_pitch():
3838
'swara': 0,
3939
'raised': True,
4040
'oct': 0,
41-
'ratios': ratios,
42-
'fundamental': 261.63,
4341
'logOffset': 0,
4442
}
4543

0 commit comments

Comments
 (0)