Skip to content

Commit a86a301

Browse files
authored
Feature/more filesystem (#2)
* major improvements incl. proper program assignments and missing confirmations * sfs filesystem, floppy, cdrom and sampler objects documentation
1 parent 0c141e1 commit a86a301

39 files changed

Lines changed: 8039 additions & 939 deletions

axklib/audio/__init__.py

Lines changed: 99 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import os
77
import wave
8+
from collections.abc import Callable
89
from dataclasses import dataclass, field
910
from pathlib import Path
1011
from typing import Literal, cast
@@ -19,11 +20,22 @@
1920
WaveExportLayout = 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)
2334
class 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)
5769
class 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)
7387
class 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)
88109
class 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)
100122
class 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)
109132
class 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)
124149
class 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

Comments
 (0)