55import json
66import os
77import wave
8+ from collections .abc import Callable
89from dataclasses import dataclass , field
910from pathlib import Path
1011from typing import Literal , cast
1920WaveExportLayout = Literal ["structured" , "flat" ]
2021
2122
23+ @dataclass (frozen = True )
24+ class WavExportProgress :
25+ """Progress event emitted while writing WAV exports."""
26+
27+ stage : str
28+ completed : int
29+ total : int
30+ message : str = ""
31+
32+
2233@dataclass (frozen = True )
2334class Waveform :
2435 """Decoded exact waveform payload and playback metadata for one SMPL object.
25-
36+
2637 Use this as the in-memory audio unit before writing WAV files or building structured export graphs."""
38+
2739 object_key : str
2840 source_image : str
2941 partition_index : int | None
@@ -39,7 +51,7 @@ class Waveform:
3951 frame_count : int
4052 stored_payload_size : int
4153 stored_payload_transform : str
42- marker_lane_payload_detected : bool
54+ alternating_byte_payload_detected : bool
4355 root_key : int | None
4456 fine_tune : int | None
4557 loop_mode : int | None
@@ -56,8 +68,9 @@ class Waveform:
5668@dataclass (frozen = True )
5769class WaveformPlacement :
5870 """Best-known sampler-facing placement for a decoded waveform.
59-
71+
6072 Use it to organize exports by partition, volume, category, and owner while keeping placement quality explicit."""
73+
6174 partition_index : int | None = None
6275 partition_name : str = ""
6376 volume_name : str = ""
@@ -67,13 +80,15 @@ class WaveformPlacement:
6780 source : str = ""
6881 relationship_path : str = ""
6982 owner_object_key : str = ""
83+ raw_volume_path : str = ""
7084
7185
7286@dataclass (frozen = True )
7387class WaveformRelationship :
7488 """Relationship edge relevant to waveform export context.
75-
89+
7690 Use it to connect exported audio back to parent SBNK, SBAC, or PROG objects without re-running relationship inference inside the exporter."""
91+
7792 source_key : str
7893 target_key : str
7994 relationship_type : str
@@ -83,12 +98,19 @@ class WaveformRelationship:
8398 ambiguity_notes : str = ""
8499 source_image : str = ""
85100 scope_key : str = ""
101+ assignment_index : int | None = None
102+ assignment_name : str = ""
103+ assignment_row_state : str = ""
104+ active_assignment_state : str = ""
105+ assignment_rch_assign_display : str = ""
106+
86107
87108@dataclass (frozen = True )
88109class WaveformIssue :
89110 """Structured waveform decode issue for one object.
90-
111+
91112 Use it when a waveform cannot be decoded exactly, has truncated payload bytes, or needs a stable warning/error code in corpus reports."""
113+
92114 object_key : str
93115 sample_name : str
94116 code : str
@@ -99,17 +121,19 @@ class WaveformIssue:
99121@dataclass (frozen = True )
100122class WaveformSet :
101123 """Batch waveform decode result.
102-
124+
103125 Use it to pass decoded waveforms and their non-fatal decode issues together through export and validation code."""
126+
104127 waveforms : tuple [Waveform , ...]
105128 issues : tuple [WaveformIssue , ...]
106129
107130
108131@dataclass (frozen = True )
109132class WavExportRequest :
110133 """Immutable request for writing decoded waveforms to disk.
111-
134+
112135 Use it to specify output directory, stereo policy, overwrite policy, layout, placement hints, and relationship hints in one testable value."""
136+
113137 output_dir : Path
114138 waveforms : tuple [Waveform , ...]
115139 stereo_policy : StereoPolicy = "auto"
@@ -118,13 +142,15 @@ class WavExportRequest:
118142 layout : WaveExportLayout = "structured"
119143 placements : dict [str , WaveformPlacement ] = field (default_factory = dict )
120144 relationships : tuple [WaveformRelationship , ...] = ()
145+ progress_callback : Callable [[WavExportProgress ], None ] | None = None
121146
122147
123148@dataclass (frozen = True )
124149class WavExportResult :
125150 """Result summary from a WAV export run.
126-
151+
127152 Use it to inspect written files, skipped files, warnings, and sidecar rows without scraping console output."""
153+
128154 written_files : tuple [Path , ...]
129155 skipped_files : tuple [Path , ...]
130156 warnings : tuple [str , ...]
@@ -178,7 +204,9 @@ def decode_waveform(sample: AxklibObject) -> Waveform:
178204 raise ValueError (f"decode_waveform requires SMPL object, got { sample .type } " )
179205 payload = sample .payload
180206 if len (payload ) < 0xAC :
181- raise ValueError (f"SMPL payload too short for current waveform decode: { len (payload )} bytes" )
207+ raise ValueError (
208+ f"SMPL payload too short for current waveform decode: { len (payload )} bytes"
209+ )
182210 if not payload .startswith (object_current .OBJECT_MAGIC ):
183211 raise ValueError ("SMPL payload does not start with FSFSDEV3SPLX magic" )
184212 if payload [0x0C :0x10 ] != b"SMPL" :
@@ -202,19 +230,19 @@ def decode_waveform(sample: AxklibObject) -> Waveform:
202230 wav_sample_width_value = decoded ["wav_sample_width_bytes" ]
203231 wav_sample_width = wav_sample_width_value if isinstance (wav_sample_width_value , int ) else 0
204232 transform = str (decoded ["stored_payload_transform" ])
205- marker_lane = bool (decoded ["marker_lane_payload_detected " ])
233+ alternating_byte = bool (decoded ["alternating_byte_payload_detected " ])
206234 metadata = object_current .decode_current_smpl_metadata (payload [:0xAC ])
207235 frame_count = len (pcm ) // wav_sample_width if wav_sample_width else 0
208236 quality = AxklibQuality (
209- quality = DataQuality .LIKELY if marker_lane else DataQuality .KNOWN ,
237+ quality = DataQuality .LIKELY if alternating_byte else DataQuality .KNOWN ,
210238 source = (
211- "direct FSFSDEV3SPLXSMPL object header plus marker-lane payload detection"
212- if marker_lane
239+ "direct FSFSDEV3SPLXSMPL object header plus alternating-byte payload detection"
240+ if alternating_byte
213241 else "direct FSFSDEV3SPLXSMPL object header and stored payload bytes"
214242 ),
215243 notes = (
216- "Current-looking marker-lane payload export is diagnostic salvage metadata, not write basis."
217- if marker_lane
244+ "Current-looking alternating-byte payload export is diagnostic compatibility metadata, not a write-support basis."
245+ if alternating_byte
218246 else "Current SMPL payload span decoded without trimming or padding."
219247 ),
220248 )
@@ -234,15 +262,17 @@ def decode_waveform(sample: AxklibObject) -> Waveform:
234262 frame_count = frame_count ,
235263 stored_payload_size = stored_payload_size ,
236264 stored_payload_transform = transform ,
237- marker_lane_payload_detected = marker_lane ,
265+ alternating_byte_payload_detected = alternating_byte ,
238266 root_key = metadata .root_key_midi_note_guess ,
239267 fine_tune = metadata .fine_tune_cents_guess ,
240268 loop_mode = metadata .loop_mode_candidate_0x085 ,
241269 loop_mode_label = metadata .loop_mode_a4000_ui_label_guess ,
242270 loop_start = metadata .loop_start_frame_0x096 ,
243271 loop_length = metadata .loop_length_frames_0x09a ,
244272 loop_end_a4000_ui = metadata .loop_end_frame_a4000_ui_guess ,
245- exactness_status = ("current-marker-lane-salvage" if marker_lane else "exact-current-mono" ),
273+ exactness_status = (
274+ "alternating-byte-compatibility-export" if alternating_byte else "exact-current-mono"
275+ ),
246276 quality = quality ,
247277 field_quality = object_current .current_smpl_field_quality (transform ),
248278 metadata = {
@@ -254,6 +284,12 @@ def decode_waveform(sample: AxklibObject) -> Waveform:
254284 "smpl_link_id_0x078" : metadata .smpl_link_id_0x078 ,
255285 "sample_rate_duplicate_0x07c" : metadata .sample_rate_duplicate_0x07c ,
256286 "wave_length_frames_0x092" : metadata .wave_length_frames_0x092 ,
287+ "iso_raw_group" : sample .metadata .get ("iso_raw_group" , "" ),
288+ "iso_raw_volume" : sample .metadata .get ("iso_raw_volume" , "" ),
289+ "iso_group_label" : sample .metadata .get ("iso_group_label" , "" ),
290+ "iso_volume_label" : sample .metadata .get ("iso_volume_label" , "" ),
291+ "iso_group_label_source" : sample .metadata .get ("iso_group_label_source" , "" ),
292+ "iso_volume_label_source" : sample .metadata .get ("iso_volume_label_source" , "" ),
257293 },
258294 )
259295
@@ -266,7 +302,11 @@ def decode_container_waveforms(
266302 for item in container .objects :
267303 if item .type != "SMPL" :
268304 continue
269- if selection and selection .lower () not in item .name .lower () and selection != item .object_key :
305+ if (
306+ selection
307+ and selection .lower () not in item .name .lower ()
308+ and selection != item .object_key
309+ ):
270310 continue
271311 try :
272312 waveforms .append (decode_waveform (item ))
@@ -283,7 +323,9 @@ def decode_container_waveforms(
283323 return WaveformSet (waveforms = tuple (waveforms ), issues = tuple (issues ))
284324
285325
286- def iter_waveforms (container : AxklibContainer , * , selection : str | None = None ) -> tuple [Waveform , ...]:
326+ def iter_waveforms (
327+ container : AxklibContainer , * , selection : str | None = None
328+ ) -> tuple [Waveform , ...]:
287329 return decode_container_waveforms (container , selection = selection ).waveforms
288330
289331
@@ -305,7 +347,7 @@ def waveform_sidecar(waveform: Waveform, wav_path: Path) -> dict[str, object]:
305347 "stored_payload_size" : waveform .stored_payload_size ,
306348 "decoded_pcm_size" : len (waveform .pcm ),
307349 "stored_payload_transform" : waveform .stored_payload_transform ,
308- "marker_lane_payload_detected " : waveform .marker_lane_payload_detected ,
350+ "alternating_byte_payload_detected " : waveform .alternating_byte_payload_detected ,
309351 "extraction_quality" : waveform .quality .quality .value ,
310352 "extraction_basis" : waveform .quality .source ,
311353 "extraction_notes" : waveform .quality .notes ,
@@ -359,10 +401,13 @@ def export_waveforms(request: WavExportRequest) -> WavExportResult:
359401 placements = request .placements ,
360402 relationships = request .relationships ,
361403 )
362- structured_written , structured_skipped , structured_warnings , structured_records = write_structured_export (
363- plan ,
364- overwrite_policy = request .overwrite_policy ,
365- stereo_policy = request .stereo_policy ,
404+ structured_written , structured_skipped , structured_warnings , structured_records = (
405+ write_structured_export (
406+ plan ,
407+ overwrite_policy = request .overwrite_policy ,
408+ stereo_policy = request .stereo_policy ,
409+ progress_callback = request .progress_callback ,
410+ )
366411 )
367412 return WavExportResult (
368413 written_files = structured_written ,
@@ -377,6 +422,15 @@ def export_waveforms(request: WavExportRequest) -> WavExportResult:
377422 records : list [dict [str , object ]] = []
378423 overwrite = request .overwrite_policy == "replace"
379424 request .output_dir .mkdir (parents = True , exist_ok = True )
425+ if request .progress_callback is not None :
426+ request .progress_callback (
427+ WavExportProgress (
428+ stage = "exporting" ,
429+ completed = 0 ,
430+ total = len (request .waveforms ),
431+ message = "writing WAV files" ,
432+ )
433+ )
380434 for index , waveform in enumerate (request .waveforms , start = 1 ):
381435 source_stem = _safe_name (Path (waveform .source_image ).stem or "source" )
382436 sample_name = _safe_name (waveform .sample_name )
@@ -405,23 +459,38 @@ def export_waveforms(request: WavExportRequest) -> WavExportResult:
405459 raise
406460 written .extend ([wav_path , sidecar_path ])
407461 records .append (record )
462+ if request .progress_callback is not None :
463+ request .progress_callback (
464+ WavExportProgress (
465+ stage = "exporting" ,
466+ completed = index ,
467+ total = len (request .waveforms ),
468+ message = waveform .sample_name ,
469+ )
470+ )
408471 manifest_rows = [
409472 {
410- "object_offset" : record .get ("object_offset" ) if record .get ("object_offset" ) is not None else "" ,
473+ "object_offset" : record .get ("object_offset" )
474+ if record .get ("object_offset" ) is not None
475+ else "" ,
411476 "name" : record .get ("name_guess" , "" ),
412477 "sample_rate" : record .get ("sample_rate" , "" ),
413478 "sample_width_bytes" : record .get ("sample_width_bytes" , "" ),
414479 "frames" : record .get ("frames" , "" ),
415480 "source_wav_path" : record .get ("wav_path" , "" ),
416481 "exported_wav_path" : record .get ("wav_path" , "" ),
417482 "exported_json_path" : str (Path (str (record .get ("wav_path" , "" ))).with_suffix (".json" )),
418- "partition_index" : record .get ("partition_index" ) if record .get ("partition_index" ) is not None else "" ,
483+ "partition_index" : record .get ("partition_index" )
484+ if record .get ("partition_index" ) is not None
485+ else "" ,
419486 "partition_name" : record .get ("partition_name" , "" ),
420487 "volume_name" : record .get ("volume_name" , "" ),
421488 "category_name" : record .get ("category_name" , "" ),
422489 "organization_source" : record .get ("organization_source" , "" ),
423490 "organization_relationship_path" : record .get ("organization_relationship_path" , "" ),
424- "organization_relationship_quality" : record .get ("organization_relationship_quality" , "" ),
491+ "organization_relationship_quality" : record .get (
492+ "organization_relationship_quality" , ""
493+ ),
425494 "organization_owner_object_offset" : "" ,
426495 }
427496 for record in records
@@ -441,7 +510,9 @@ def export_waveforms(request: WavExportRequest) -> WavExportResult:
441510 os .replace (tmp_manifest_path , manifest_path )
442511 written .append (manifest_path )
443512 if request .stereo_policy == "auto" :
444- warnings .append ("stereo auto policy is reserved for the shared stereo service; mono exact exports were written" )
513+ warnings .append (
514+ "stereo auto policy is reserved for the shared stereo service; mono exact exports were written"
515+ )
445516 return WavExportResult (
446517 written_files = tuple (written ),
447518 skipped_files = tuple (skipped ),
0 commit comments