844 lines
39 KiB
Python
844 lines
39 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate wiki media preview pages for exported sounds and openable assets."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import json
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
|
|
WIKI_ROOT = Path(__file__).resolve().parents[1]
|
|
WORKSPACE_ROOT = WIKI_ROOT.parent
|
|
SOUND_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/sound_export_manifest.tsv"
|
|
NATIVE_NAMED_SOUND_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/native_named_sound_manifest.tsv"
|
|
NATIVE_SOUND_TEST_LABEL_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/native_sound_test_label_manifest.tsv"
|
|
ASR_DRAFT_SOUND_LABEL_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/asr_draft_sound_labels.tsv"
|
|
ASR_REVIEW_SOUND_LABEL_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/asr_review_sound_label_candidates.tsv"
|
|
ASR_CATALOG_SOUND_LABEL_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/asr_catalog_sound_label_candidates.tsv"
|
|
CATALOG_OFFSET_CANDIDATE_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/catalog_offset_candidate_labels.tsv"
|
|
POKEDEX_VOICE_LABEL_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/pokedex_voice_label_manifest.tsv"
|
|
SPIKE_MENU_SOUND_MANIFEST = WORKSPACE_ROOT / "analysis/radium-spike-menu-image/sound_export_manifest.tsv"
|
|
BEST_NAMED_SOUND_MANIFEST = WORKSPACE_ROOT / "analysis/all-sound-best-name-manifest.tsv"
|
|
RADIUM_SECTION3_BITMAP_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/section3_bitmap_manifest.tsv"
|
|
ASSET_ROOT = WORKSPACE_ROOT / "assets/openable"
|
|
OUT_MANIFEST = WORKSPACE_ROOT / "analysis/media-preview-pages.json"
|
|
|
|
SOUND_PAGE_SIZE = 200
|
|
IMAGE_PAGE_SIZE = 200
|
|
VIDEO_PAGE_SIZE = 100
|
|
TEXT_PAGE_SIZE = 200
|
|
RADIUM_BITMAP_PAGE_SIZE = 240
|
|
|
|
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
|
|
VIDEO_EXTS = {".mp4", ".mov", ".webm", ".m4v"}
|
|
TEXT_EXTS = {".txt", ".json", ".csv", ".tsv", ".md", ".import"}
|
|
FONT_EXTS = {".ttf", ".otf", ".woff", ".woff2"}
|
|
|
|
|
|
def clean_attr(value: object) -> str:
|
|
return str(value).replace('"', "'").replace("\n", " ").replace("\r", " ")
|
|
|
|
|
|
def wiki_src(path: Path) -> str:
|
|
rel = path.relative_to(WIKI_ROOT).as_posix() if path.is_relative_to(WIKI_ROOT) else ("../" + path.relative_to(WORKSPACE_ROOT).as_posix())
|
|
return quote(rel, safe="/._-~:;@!$&'()*+,=")
|
|
|
|
|
|
def write_page(name: str, text: str) -> str:
|
|
path = WIKI_ROOT / name
|
|
path.write_text(text.rstrip() + "\n", encoding="utf-8")
|
|
return name
|
|
|
|
|
|
def chunks(items: list[dict[str, object]], size: int) -> list[list[dict[str, object]]]:
|
|
return [items[i : i + size] for i in range(0, len(items), size)]
|
|
|
|
|
|
def sound_rows() -> list[dict[str, str]]:
|
|
with SOUND_MANIFEST.open(newline="") as f:
|
|
rows = list(csv.DictReader(f, delimiter="\t"))
|
|
native_by_source: dict[str, dict[str, str]] = {}
|
|
if NATIVE_NAMED_SOUND_MANIFEST.exists():
|
|
with NATIVE_NAMED_SOUND_MANIFEST.open(newline="") as f:
|
|
for native in csv.DictReader(f, delimiter="\t"):
|
|
native_by_source[native["source_wav_path"]] = native
|
|
asr_by_source: dict[str, dict[str, str]] = {}
|
|
if ASR_DRAFT_SOUND_LABEL_MANIFEST.exists():
|
|
with ASR_DRAFT_SOUND_LABEL_MANIFEST.open(newline="") as f:
|
|
for draft in csv.DictReader(f, delimiter="\t"):
|
|
if draft["accepted"] == "yes":
|
|
asr_by_source[draft["wav_path"]] = draft
|
|
for row in rows:
|
|
native = native_by_source.get(row["wav_path"])
|
|
row["confirmed_name"] = native["name"] if native else ""
|
|
row["confirmed_alias_wav_path"] = native["alias_wav_path"] if native else ""
|
|
row["sound_test_label"] = ""
|
|
row["sound_test_alias_wav_path"] = ""
|
|
asr = asr_by_source.get(row["wav_path"])
|
|
row["asr_draft_label"] = asr["transcript"] if asr else ""
|
|
row["asr_draft_score"] = asr["avg_logprob"] if asr else ""
|
|
return sorted(rows, key=lambda row: row["sound_name"])
|
|
|
|
|
|
def native_named_sound_rows() -> list[dict[str, str]]:
|
|
if not NATIVE_NAMED_SOUND_MANIFEST.exists():
|
|
return []
|
|
with NATIVE_NAMED_SOUND_MANIFEST.open(newline="") as f:
|
|
return sorted(csv.DictReader(f, delimiter="\t"), key=lambda row: int(row["native_sound_id"]))
|
|
|
|
|
|
def native_sound_test_label_rows() -> list[dict[str, str]]:
|
|
if not NATIVE_SOUND_TEST_LABEL_MANIFEST.exists():
|
|
return []
|
|
with NATIVE_SOUND_TEST_LABEL_MANIFEST.open(newline="") as f:
|
|
return sorted(csv.DictReader(f, delimiter="\t"), key=lambda row: int(row["sound_id"]))
|
|
|
|
|
|
def asr_draft_sound_label_rows() -> list[dict[str, str]]:
|
|
if not ASR_DRAFT_SOUND_LABEL_MANIFEST.exists():
|
|
return []
|
|
with ASR_DRAFT_SOUND_LABEL_MANIFEST.open(newline="") as f:
|
|
rows = [row for row in csv.DictReader(f, delimiter="\t") if row["accepted"] == "yes"]
|
|
return sorted(rows, key=lambda row: int(row["section8_index"]))
|
|
|
|
|
|
def asr_review_sound_label_rows() -> list[dict[str, str]]:
|
|
if not ASR_REVIEW_SOUND_LABEL_MANIFEST.exists():
|
|
return []
|
|
with ASR_REVIEW_SOUND_LABEL_MANIFEST.open(newline="", encoding="utf-8") as f:
|
|
rows = list(csv.DictReader(f, delimiter="\t"))
|
|
return sorted(rows, key=lambda row: int(row["section8_index"]))
|
|
|
|
|
|
def asr_catalog_sound_label_rows() -> list[dict[str, str]]:
|
|
if not ASR_CATALOG_SOUND_LABEL_MANIFEST.exists():
|
|
return []
|
|
with ASR_CATALOG_SOUND_LABEL_MANIFEST.open(newline="", encoding="utf-8") as f:
|
|
rows = list(csv.DictReader(f, delimiter="\t"))
|
|
return sorted(rows, key=lambda row: int(row["section8_index"]))
|
|
|
|
|
|
def catalog_offset_candidate_rows() -> list[dict[str, str]]:
|
|
if not CATALOG_OFFSET_CANDIDATE_MANIFEST.exists():
|
|
return []
|
|
with CATALOG_OFFSET_CANDIDATE_MANIFEST.open(newline="", encoding="utf-8") as f:
|
|
rows = list(csv.DictReader(f, delimiter="\t"))
|
|
return sorted(rows, key=lambda row: (row["block_name"], int(row["candidate_object_id"])))
|
|
|
|
|
|
def pokedex_voice_label_rows() -> list[dict[str, str]]:
|
|
if not POKEDEX_VOICE_LABEL_MANIFEST.exists():
|
|
return []
|
|
with POKEDEX_VOICE_LABEL_MANIFEST.open(newline="", encoding="utf-8") as f:
|
|
return sorted(csv.DictReader(f, delimiter="\t"), key=lambda row: int(row["object_id"]))
|
|
|
|
|
|
def spike_menu_sound_rows() -> list[dict[str, str]]:
|
|
if not SPIKE_MENU_SOUND_MANIFEST.exists():
|
|
return []
|
|
with SPIKE_MENU_SOUND_MANIFEST.open(newline="") as f:
|
|
return sorted(csv.DictReader(f, delimiter="\t"), key=lambda row: row["sound_name"])
|
|
|
|
|
|
def best_named_sound_rows() -> list[dict[str, str]]:
|
|
if not BEST_NAMED_SOUND_MANIFEST.exists():
|
|
return []
|
|
with BEST_NAMED_SOUND_MANIFEST.open(newline="", encoding="utf-8") as f:
|
|
return sorted(
|
|
csv.DictReader(f, delimiter="\t"),
|
|
key=lambda row: (row["bank"], row["name_source"], int(row["section8_index"])),
|
|
)
|
|
|
|
|
|
def unresolved_best_named_rows(best_named_rows: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
return sorted(
|
|
[
|
|
row
|
|
for row in best_named_rows
|
|
if row["name_source"] in {"object_ref", "spike_menu_object_ref"}
|
|
],
|
|
key=lambda row: (row["bank"], int(row["object_id"]), int(row["section8_index"])),
|
|
)
|
|
|
|
|
|
def asset_rows() -> dict[str, list[dict[str, object]]]:
|
|
rows = {"images": [], "videos": [], "text": [], "fonts": []}
|
|
for path in sorted(ASSET_ROOT.rglob("*")):
|
|
if not path.is_file() or path.name == ".DS_Store":
|
|
continue
|
|
ext = path.suffix.lower()
|
|
rel = path.relative_to(WORKSPACE_ROOT).as_posix()
|
|
row = {"path": path, "rel": rel, "name": path.name, "size": path.stat().st_size}
|
|
if ext in IMAGE_EXTS:
|
|
rows["images"].append(row)
|
|
elif ext in VIDEO_EXTS:
|
|
rows["videos"].append(row)
|
|
elif ext in TEXT_EXTS:
|
|
rows["text"].append(row)
|
|
elif ext in FONT_EXTS:
|
|
rows["fonts"].append(row)
|
|
return rows
|
|
|
|
|
|
def radium_section3_bitmap_rows() -> list[dict[str, str]]:
|
|
if not RADIUM_SECTION3_BITMAP_MANIFEST.exists():
|
|
return []
|
|
with RADIUM_SECTION3_BITMAP_MANIFEST.open(newline="", encoding="utf-8") as f:
|
|
rows = [
|
|
row
|
|
for row in csv.DictReader(f, delimiter="\t")
|
|
if row["decoder_status"] == "raw_indexed_preview" and row["png_path"]
|
|
]
|
|
return sorted(rows, key=lambda row: int(row["logical_index"]))
|
|
|
|
|
|
def page_nav(page_names: list[str]) -> str:
|
|
return "\n".join(f"- [{name[:-3].replace('-', ' ').title()}]({name})" for name in page_names)
|
|
|
|
|
|
def sound_directive(row: dict[str, str]) -> str:
|
|
meta = (
|
|
f"section8={row['section8_index']} object={row['primary_object_local_id'] or 'unreferenced'} "
|
|
f"duration={row['duration_seconds']}s channels={row['channels']} refs={row['command_ref_count']}"
|
|
)
|
|
title = row["sound_name"]
|
|
if row.get("confirmed_name"):
|
|
title = f"{row['confirmed_name']} ({row['sound_name']})"
|
|
elif row.get("asr_draft_label"):
|
|
title = f"ASR draft: {row['asr_draft_label']} ({row['sound_name']})"
|
|
return (
|
|
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["wav_path"])}" '
|
|
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def native_named_sound_directive(row: dict[str, str]) -> str:
|
|
meta = (
|
|
f"native_id={row['native_sound_id']} section8={row['section8_index']} "
|
|
f"duration={row['duration_seconds']}s channels={row['channels']} "
|
|
f"register={row['register_call_ghidra']} string={row['string_ghidra']}"
|
|
)
|
|
return (
|
|
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["alias_wav_path"])}" '
|
|
f'title="{clean_attr(row["name"])}" meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def native_sound_test_label_directive(row: dict[str, str]) -> str:
|
|
meta = (
|
|
f"sound_id={row['sound_id']} section8={row['section8_index']} "
|
|
f"duration={row['duration_seconds']}s channels={row['channels']} "
|
|
f"table={row['table_entry_offset']} string={row['label_string_offset']}"
|
|
)
|
|
return (
|
|
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["alias_wav_path"])}" '
|
|
f'title="{clean_attr(row["label"])}" meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def asr_draft_sound_label_directive(row: dict[str, str]) -> str:
|
|
meta = (
|
|
f"object={row['object_id']} section8={row['section8_index']} "
|
|
f"duration={row['duration_seconds']}s channels={row['channels']} "
|
|
f"model={row['model']} logprob={row['avg_logprob']} no_speech={row['no_speech_prob']}"
|
|
)
|
|
return (
|
|
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["wav_path"])}" '
|
|
f'title="ASR draft: {clean_attr(row["transcript"])}" meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def asr_review_sound_label_directive(row: dict[str, str]) -> str:
|
|
meta = (
|
|
f"object={row['object_id']} section8={row['section8_index']} "
|
|
f"duration={row['duration_seconds']}s channels={row['channels']} "
|
|
f"rule={row['review_rule']} model={row['model']} "
|
|
f"logprob={row['avg_logprob']} no_speech={row['no_speech_prob']}"
|
|
)
|
|
return (
|
|
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["wav_path"])}" '
|
|
f'title="ASR review: {clean_attr(row["transcript"])}" meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def asr_catalog_sound_label_directive(row: dict[str, str]) -> str:
|
|
meta = (
|
|
f"object={row['object_id']} section8={row['section8_index']} "
|
|
f"duration={row['duration_seconds']}s channels={row['channels']} "
|
|
f"match={row['match_kind']} phrase={row['match_phrase']} "
|
|
f"model={row['model']} logprob={row['avg_logprob']}"
|
|
)
|
|
title = f"{row['catalog_constant']} (ASR: {row['transcript']})"
|
|
return (
|
|
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["wav_path"])}" '
|
|
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def catalog_offset_candidate_directive(row: dict[str, str]) -> str:
|
|
meta = (
|
|
f"status={row['candidate_status']} block={row['block_name']} "
|
|
f"range={row['range_kind']} catalog_index={row['catalog_index']} "
|
|
f"object={row['candidate_object_id']} section8={row['section8_index']} "
|
|
f"offset={row['offset']} current={row['current_best_source']}"
|
|
)
|
|
if row.get("sound_test_label"):
|
|
meta += f" sound_test={row['sound_test_label']}"
|
|
title = f"{row['catalog_constant']} [{row['candidate_status']}]"
|
|
return (
|
|
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["source_wav_path"])}" '
|
|
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def pokedex_voice_label_directive(row: dict[str, str]) -> str:
|
|
role = row["role"]
|
|
if role == "description" and row["role_variant"] != "1":
|
|
role = f"description alt {row['role_variant']}"
|
|
meta = (
|
|
f"pokemon={int(row['pokemon_number']):04d} role={role} object={row['object_id']} "
|
|
f"section8={row['section8_index']} duration={row['duration_seconds']}s "
|
|
f"score={row['alignment_score']} action={row['alignment_action']}"
|
|
)
|
|
title = f"Pokedex {int(row['pokemon_number']):04d} {row['pokemon_name']} {role}"
|
|
return (
|
|
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["alias_wav_path"])}" '
|
|
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def spike_menu_sound_directive(row: dict[str, str]) -> str:
|
|
meta = (
|
|
f"section8={row['section8_index']} object={row['primary_object_local_id']} "
|
|
f"duration={row['duration_seconds']}s channels={row['channels']} refs={row['command_ref_count']}"
|
|
)
|
|
return (
|
|
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["wav_path"])}" '
|
|
f'title="SPIKE menu: {clean_attr(row["sound_name"])}" meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def best_named_sound_directive(row: dict[str, str]) -> str:
|
|
meta = (
|
|
f"bank={row['bank']} source={row['name_source']} confidence={row['confidence']} "
|
|
f"section8={row['section8_index']} object={row['object_id']} "
|
|
f"duration={row['duration_seconds']}s channels={row['channels']} refs={row['command_ref_count']}"
|
|
)
|
|
title = f"{row['canonical_name']} [{row['name_source']}]"
|
|
return (
|
|
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["alias_wav_path"])}" '
|
|
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def unresolved_sound_review_kind(row: dict[str, str]) -> str:
|
|
duration = float(row["duration_seconds"])
|
|
channels = int(row["channels"])
|
|
if channels == 2 and duration >= 30:
|
|
return "long_stereo_music_candidate"
|
|
if channels == 2 and duration >= 10:
|
|
return "mid_stereo_music_or_ambience_candidate"
|
|
if duration < 0.25:
|
|
return "very_short_fx_candidate"
|
|
if channels == 1:
|
|
return "mono_fx_or_voice_reject"
|
|
return "stereo_fx_candidate"
|
|
|
|
|
|
def unresolved_sound_directive(row: dict[str, str]) -> str:
|
|
review_kind = unresolved_sound_review_kind(row)
|
|
meta = (
|
|
f"bank={row['bank']} review={review_kind} section8={row['section8_index']} "
|
|
f"object={row['object_id']} duration={row['duration_seconds']}s "
|
|
f"channels={row['channels']} refs={row['command_ref_count']}"
|
|
)
|
|
title = f"{row['canonical_name']} [{review_kind}]"
|
|
return (
|
|
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["alias_wav_path"])}" '
|
|
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def asset_directive(kind: str, row: dict[str, object], index: int) -> str:
|
|
path = row["path"]
|
|
title = row["rel"]
|
|
meta = f"{row['size']} bytes"
|
|
if kind == "images":
|
|
directive = "image"
|
|
elif kind == "videos":
|
|
directive = "video"
|
|
elif kind == "fonts":
|
|
directive = "font"
|
|
else:
|
|
directive = "textasset"
|
|
extra = f' family="asset_font_{index:03d}"' if directive == "font" else ""
|
|
return (
|
|
f'::{directive}{{src="{wiki_src(path)}" title="{clean_attr(title)}" '
|
|
f'meta="{clean_attr(meta)}"{extra}}}'
|
|
)
|
|
|
|
|
|
def radium_section3_bitmap_directive(row: dict[str, str]) -> str:
|
|
path = WORKSPACE_ROOT / row["png_path"]
|
|
title = f"Section 3 {int(row['logical_index']):04d} record {row['record_id']}"
|
|
meta = (
|
|
f"offset={row['record_offset']} flags={row['flags']} "
|
|
f"size={row['width']}x{row['height']} payload={row['pixel_payload_length']} "
|
|
f"status={row['decoder_status']}"
|
|
)
|
|
return (
|
|
f'::image{{src="{wiki_src(path)}" title="{clean_attr(title)}" '
|
|
f'meta="{clean_attr(meta)}"}}'
|
|
)
|
|
|
|
|
|
def build_sound_pages(
|
|
rows: list[dict[str, str]],
|
|
native_rows: list[dict[str, str]],
|
|
sound_test_rows: list[dict[str, str]],
|
|
asr_rows: list[dict[str, str]],
|
|
asr_review_rows: list[dict[str, str]],
|
|
asr_catalog_rows: list[dict[str, str]],
|
|
catalog_offset_rows: list[dict[str, str]],
|
|
pokedex_rows: list[dict[str, str]],
|
|
spike_menu_rows: list[dict[str, str]],
|
|
best_named_rows: list[dict[str, str]],
|
|
) -> list[str]:
|
|
page_names: list[str] = []
|
|
for page_number, page_rows in enumerate(chunks(rows, SOUND_PAGE_SIZE), start=1):
|
|
name = f"sound-gallery-{page_number:02d}.md"
|
|
first = (page_number - 1) * SOUND_PAGE_SIZE + 1
|
|
last = first + len(page_rows) - 1
|
|
lines = [
|
|
f"# Sound Gallery {page_number:02d}",
|
|
"",
|
|
f"Exported Radium PCM sounds {first}-{last} of {len(rows)}.",
|
|
"",
|
|
"::gallery-start",
|
|
*[sound_directive(row) for row in page_rows],
|
|
"::gallery-end",
|
|
]
|
|
page_names.append(write_page(name, "\n".join(lines)))
|
|
best_named_pages: list[str] = []
|
|
if best_named_rows:
|
|
for page_number, page_rows in enumerate(chunks(best_named_rows, SOUND_PAGE_SIZE), start=1):
|
|
name = f"sound-best-named-gallery-{page_number:02d}.md"
|
|
first = (page_number - 1) * SOUND_PAGE_SIZE + 1
|
|
last = first + len(page_rows) - 1
|
|
lines = [
|
|
f"# Best-Named Sound Export {page_number:02d}",
|
|
"",
|
|
f"Best-available named aliases {first}-{last} of {len(best_named_rows)}.",
|
|
"Name provenance is preserved in the `source` metadata: confirmed native, Pokedex voice alignment, catalog-matched ASR, draft ASR, catalog-offset candidate, low-confidence ASR review candidate, main-image hash match, or structural object reference.",
|
|
"",
|
|
"::gallery-start",
|
|
*[best_named_sound_directive(row) for row in page_rows],
|
|
"::gallery-end",
|
|
]
|
|
best_named_pages.append(write_page(name, "\n".join(lines)))
|
|
source_counts: dict[str, int] = {}
|
|
for row in best_named_rows:
|
|
source_counts[row["name_source"]] = source_counts.get(row["name_source"], 0) + 1
|
|
source_lines = [f"- `{source}`: `{count}`" for source, count in sorted(source_counts.items())]
|
|
best_named_index = write_page(
|
|
"sound-best-named-export.md",
|
|
"\n".join(
|
|
[
|
|
"# Best-Named Sound Export",
|
|
"",
|
|
f"Generated best-available named alias previews for `{len(best_named_rows)}` total sounds across the main game and SPIKE menu images.",
|
|
"",
|
|
"The canonical WAV exports remain unchanged. These aliases are generated under `analysis/radium-image/sounds-best-named/` and `analysis/radium-spike-menu-image/sounds-best-named/` so every sound has one stable, traceable, previewable filename using the best current evidence.",
|
|
"",
|
|
"## Name Sources",
|
|
"",
|
|
*source_lines,
|
|
"",
|
|
"Confirmed native names are preferred first. Pokedex voice alignment, catalog-matched ASR names, accepted raw ASR labels, and catalog-offset candidates are used before low-confidence ASR review candidates. Catalog ASR names are inferred from exact transcript-to-catalog phrase matches; raw ASR names are marked draft. Catalog-offset names are marked candidate_unpromoted and remain below direct content evidence. Low-confidence ASR review candidates are only used when no stronger semantic label exists. Object-reference names are structural fallbacks for sounds that still lack semantic labels.",
|
|
"",
|
|
"## Gallery Pages",
|
|
"",
|
|
page_nav(best_named_pages),
|
|
"",
|
|
"## Manifest",
|
|
"",
|
|
"- `analysis/all-sound-best-name-manifest.tsv`",
|
|
]
|
|
),
|
|
)
|
|
else:
|
|
best_named_index = ""
|
|
unresolved_page = ""
|
|
unresolved_rows = unresolved_best_named_rows(best_named_rows)
|
|
if unresolved_rows:
|
|
review_counts: dict[str, int] = {}
|
|
bank_counts: dict[str, int] = {}
|
|
for row in unresolved_rows:
|
|
review_kind = unresolved_sound_review_kind(row)
|
|
review_counts[review_kind] = review_counts.get(review_kind, 0) + 1
|
|
bank_counts[row["bank"]] = bank_counts.get(row["bank"], 0) + 1
|
|
review_lines = [f"- `{kind}`: `{count}`" for kind, count in sorted(review_counts.items())]
|
|
bank_lines = [f"- `{bank}`: `{count}`" for bank, count in sorted(bank_counts.items())]
|
|
unresolved_lines = [
|
|
"# Unresolved Structural Sound Review",
|
|
"",
|
|
"These are the remaining best-name rows whose current labels are structural object references rather than semantic names.",
|
|
"The `review` metadata is a duration/channel triage hint only; it is not a recovered native or Godot symbol.",
|
|
"",
|
|
"## Bank Counts",
|
|
"",
|
|
*bank_lines,
|
|
"",
|
|
"## Review Buckets",
|
|
"",
|
|
*review_lines,
|
|
"",
|
|
"::gallery-start",
|
|
*[unresolved_sound_directive(row) for row in unresolved_rows],
|
|
"::gallery-end",
|
|
]
|
|
unresolved_page = write_page("sound-unresolved-review.md", "\n".join(unresolved_lines))
|
|
named_page = ""
|
|
if native_rows:
|
|
named_lines = [
|
|
"# Confirmed Native Sound Names",
|
|
"",
|
|
"These names are confirmed from native `GodotSpikeBridge` registration calls in `/games/pokemon_pro/game`.",
|
|
"The alias WAVs are hardlinks or symlinks under `analysis/radium-image/sounds-native-named/` that point back to the full export.",
|
|
"",
|
|
"::gallery-start",
|
|
*[native_named_sound_directive(row) for row in native_rows],
|
|
"::gallery-end",
|
|
]
|
|
named_page = write_page("sound-native-names.md", "\n".join(named_lines))
|
|
sound_test_page = ""
|
|
if sound_test_rows:
|
|
sound_test_lines = [
|
|
"# Native Sound Test Labels",
|
|
"",
|
|
"These labels are raw candidate rows from a native sound-test-adjacent table in `/games/pokemon_pro/game`.",
|
|
"Follow-up checks showed the table's numeric field is not a reliable direct Radium local sound ID for every row, so these labels are not promoted into the best-name export.",
|
|
"The alias WAVs are retained under `analysis/radium-image/sounds-native-sound-test/` for audit only.",
|
|
"",
|
|
"::gallery-start",
|
|
*[native_sound_test_label_directive(row) for row in sound_test_rows],
|
|
"::gallery-end",
|
|
]
|
|
sound_test_page = write_page("sound-native-sound-test.md", "\n".join(sound_test_lines))
|
|
asr_page = ""
|
|
if asr_rows:
|
|
asr_lines = [
|
|
"# Draft ASR Sound Labels",
|
|
"",
|
|
"These labels are draft content transcripts generated locally with Whisper from exported WAVs.",
|
|
"They are useful for triage and preview search, but they are not native symbols and may contain recognition errors.",
|
|
"",
|
|
"::gallery-start",
|
|
*[asr_draft_sound_label_directive(row) for row in asr_rows],
|
|
"::gallery-end",
|
|
]
|
|
asr_page = write_page("sound-asr-draft-labels.md", "\n".join(asr_lines))
|
|
asr_review_page = ""
|
|
if asr_review_rows:
|
|
asr_review_lines = [
|
|
"# Low-Confidence ASR Review Candidates",
|
|
"",
|
|
"These labels are lower-confidence ASR transcripts for sounds that would otherwise be structural object references.",
|
|
"They are weaker than accepted draft ASR labels and are not native symbols; the `rule`, `logprob`, and `no_speech` metadata are included so candidates can be audited while listening.",
|
|
"",
|
|
"::gallery-start",
|
|
*[asr_review_sound_label_directive(row) for row in asr_review_rows],
|
|
"::gallery-end",
|
|
]
|
|
asr_review_page = write_page("sound-asr-review-candidates.md", "\n".join(asr_review_lines))
|
|
asr_catalog_page = ""
|
|
if asr_catalog_rows:
|
|
asr_catalog_lines = [
|
|
"# Catalog-Matched ASR Sound Labels",
|
|
"",
|
|
"These labels are accepted ASR transcripts that exactly match known `SE_PK_VO_*` Godot catalog constants or narrow documented ASR error variants.",
|
|
"They are stronger than raw transcript slugs for browsing, but remain inferred because no general native/Radium symbol-to-object map has been recovered.",
|
|
"",
|
|
"::gallery-start",
|
|
*[asr_catalog_sound_label_directive(row) for row in asr_catalog_rows],
|
|
"::gallery-end",
|
|
]
|
|
asr_catalog_page = write_page("sound-asr-catalog-labels.md", "\n".join(asr_catalog_lines))
|
|
catalog_offset_page = ""
|
|
if catalog_offset_rows:
|
|
status_counts: dict[str, int] = {}
|
|
for row in catalog_offset_rows:
|
|
status_counts[row["candidate_status"]] = status_counts.get(row["candidate_status"], 0) + 1
|
|
status_lines = [f"- `{status}`: `{count}`" for status, count in sorted(status_counts.items())]
|
|
catalog_offset_lines = [
|
|
"# Catalog-Offset Candidate Sound Labels",
|
|
"",
|
|
"These previews are audit-only candidate labels from comparing Godot sound-catalog order against the two Ghidra-confirmed native registration anchor blocks.",
|
|
"Non-conflicting rows that previously had only structural object names are used in the best-name export as `catalog_offset_candidate` aliases with `candidate_unpromoted` confidence; the sound-test disagreement row and rows with stronger content evidence are not promoted from this table.",
|
|
"",
|
|
"## Candidate Status Counts",
|
|
"",
|
|
*status_lines,
|
|
"",
|
|
"::gallery-start",
|
|
*[catalog_offset_candidate_directive(row) for row in catalog_offset_rows],
|
|
"::gallery-end",
|
|
]
|
|
catalog_offset_page = write_page("sound-catalog-offset-candidates.md", "\n".join(catalog_offset_lines))
|
|
pokedex_page = ""
|
|
if pokedex_rows:
|
|
duplicate_count = sum(1 for row in pokedex_rows if row["alignment_action"] == "duplicate_description")
|
|
pokedex_lines = [
|
|
"# Pokedex Voice Alignment",
|
|
"",
|
|
"These labels are aligned from the Radium object stream, `assets/godot_raw/data/pokemon_data.txt`, and local ASR transcripts.",
|
|
f"The stream covers `{len(pokedex_rows)}` clips from Radium objects `409` through `1545`, including `{duplicate_count}` duplicate description variants.",
|
|
"The alias WAVs are hardlinks or symlinks under `analysis/radium-image/sounds-pokedex-voice/` that point back to the full export.",
|
|
"",
|
|
"::gallery-start",
|
|
*[pokedex_voice_label_directive(row) for row in pokedex_rows],
|
|
"::gallery-end",
|
|
]
|
|
pokedex_page = write_page("sound-pokedex-voice.md", "\n".join(pokedex_lines))
|
|
spike_menu_page = ""
|
|
if spike_menu_rows:
|
|
spike_menu_lines = [
|
|
"# SPIKE Menu Sound Export",
|
|
"",
|
|
"These WAVs are exported from `/games/pokemon_pro/spike3/spike_menu/image.bin`.",
|
|
"The parser uses the same section 8 PCM table format and a SPIKE-menu-specific opcode-size table at `0x410ce8` to resolve section 5 command references.",
|
|
"",
|
|
"::gallery-start",
|
|
*[spike_menu_sound_directive(row) for row in spike_menu_rows],
|
|
"::gallery-end",
|
|
]
|
|
spike_menu_page = write_page("sound-spike-menu.md", "\n".join(spike_menu_lines))
|
|
confirmed_count = sum(1 for row in rows if row.get("confirmed_name"))
|
|
asr_count = sum(1 for row in rows if row.get("asr_draft_label"))
|
|
index_lines = [
|
|
"# Full Sound Export",
|
|
"",
|
|
f"Generated preview pages for `{len(rows)}` exported WAV files under `analysis/radium-image/sounds/`.",
|
|
f"`{confirmed_count}` exported sounds currently have Ghidra-confirmed native names and aliases under "
|
|
"`analysis/radium-image/sounds-native-named/`.",
|
|
f"`{len(sound_test_rows)}` raw sound-test-adjacent table rows are documented under "
|
|
"`analysis/radium-image/native_sound_test_label_manifest.tsv`; they are not treated as confirmed best-name labels.",
|
|
f"`{asr_count}` exported sounds currently have accepted draft ASR transcript labels in "
|
|
"`analysis/radium-image/asr_draft_sound_labels.tsv`.",
|
|
f"`{len(asr_review_rows)}` rejected ASR rows pass conservative review-candidate filters in "
|
|
"`analysis/radium-image/asr_review_sound_label_candidates.tsv`; only rows without stronger names are used as low-confidence best-name aliases.",
|
|
f"`{len(asr_catalog_rows)}` ASR-labeled sounds also match known Godot voice catalog constants in "
|
|
"`analysis/radium-image/asr_catalog_sound_label_candidates.tsv`.",
|
|
f"`{len(catalog_offset_rows)}` catalog-order offset candidate rows are previewable in "
|
|
"`analysis/radium-image/catalog_offset_candidate_labels.tsv`; these are audit-only and are not promoted.",
|
|
f"`{len(pokedex_rows)}` Pokedex voice clips have aligned semantic labels in "
|
|
"`analysis/radium-image/pokedex_voice_label_manifest.tsv`.",
|
|
f"`{len(spike_menu_rows)}` SPIKE menu sounds are exported under `analysis/radium-spike-menu-image/sounds/`.",
|
|
f"`{len(best_named_rows)}` sounds have complete best-available named aliases under "
|
|
"`analysis/radium-image/sounds-best-named/` and `analysis/radium-spike-menu-image/sounds-best-named/`.",
|
|
f"`{len(unresolved_rows)}` best-name rows still have structural object-reference labels and are grouped for review in `sound-unresolved-review.md`.",
|
|
"",
|
|
"Names are deterministic static names based on the best resolved Radium command reference: "
|
|
"`objNNNN_vV_cmdC_sSSSS_channels_rate.wav`.",
|
|
"",
|
|
"Confirmed native names come from `GodotSpikeBridge` registration calls recovered from `/games/pokemon_pro/game`. "
|
|
"Raw sound-test-adjacent labels are preserved for audit but are not promoted as confirmed names. "
|
|
"ASR draft and review labels are content-derived transcripts and are not treated as confirmed symbols. "
|
|
"The remaining sounds keep object/chunk names until a broader native or bytecode symbol-to-object map is recovered.",
|
|
"",
|
|
"## Gallery Pages",
|
|
"",
|
|
f"- [Confirmed Native Sound Names]({named_page})" if named_page else "",
|
|
f"- [Native Sound Test Labels]({sound_test_page})" if sound_test_page else "",
|
|
f"- [Draft ASR Sound Labels]({asr_page})" if asr_page else "",
|
|
f"- [Low-Confidence ASR Review Candidates]({asr_review_page})" if asr_review_page else "",
|
|
f"- [Catalog-Matched ASR Sound Labels]({asr_catalog_page})" if asr_catalog_page else "",
|
|
f"- [Catalog-Offset Candidate Sound Labels]({catalog_offset_page})" if catalog_offset_page else "",
|
|
f"- [Pokedex Voice Alignment]({pokedex_page})" if pokedex_page else "",
|
|
f"- [SPIKE Menu Sound Export]({spike_menu_page})" if spike_menu_page else "",
|
|
f"- [Best-Named Sound Export]({best_named_index})" if best_named_index else "",
|
|
f"- [Unresolved Structural Sound Review]({unresolved_page})" if unresolved_page else "",
|
|
page_nav(page_names),
|
|
"",
|
|
"## Manifests",
|
|
"",
|
|
"- `analysis/radium-image/sound_export_manifest.tsv`",
|
|
"- `analysis/radium-image/sound_export_manifest.json`",
|
|
"- `analysis/radium-image/command_sound_refs.tsv`",
|
|
"- `analysis/radium-image/native_sound_id_map.tsv`",
|
|
"- `analysis/radium-image/native_named_sound_manifest.tsv`",
|
|
"- `analysis/radium-image/native_sound_test_label_manifest.tsv`",
|
|
"- `analysis/radium-image/asr_draft_sound_labels.tsv`",
|
|
"- `analysis/radium-image/asr_review_sound_label_candidates.tsv`",
|
|
"- `analysis/radium-image/asr_catalog_sound_label_candidates.tsv`",
|
|
"- `analysis/radium-image/catalog_offset_candidate_labels.tsv`",
|
|
"- `analysis/radium-image/pokedex_voice_label_manifest.tsv`",
|
|
"- `analysis/radium-spike-menu-image/sound_export_manifest.tsv`",
|
|
"- `analysis/all-sound-best-name-manifest.tsv`",
|
|
]
|
|
write_page("sound-export.md", "\n".join(index_lines))
|
|
return [
|
|
"sound-export.md",
|
|
*([named_page] if named_page else []),
|
|
*([sound_test_page] if sound_test_page else []),
|
|
*([asr_page] if asr_page else []),
|
|
*([asr_review_page] if asr_review_page else []),
|
|
*([asr_catalog_page] if asr_catalog_page else []),
|
|
*([catalog_offset_page] if catalog_offset_page else []),
|
|
*([pokedex_page] if pokedex_page else []),
|
|
*([spike_menu_page] if spike_menu_page else []),
|
|
*([best_named_index] if best_named_index else []),
|
|
*([unresolved_page] if unresolved_page else []),
|
|
*best_named_pages,
|
|
*page_names,
|
|
]
|
|
|
|
|
|
def build_asset_pages(rows_by_kind: dict[str, list[dict[str, object]]]) -> list[str]:
|
|
settings = {
|
|
"images": ("Asset Image Gallery", IMAGE_PAGE_SIZE),
|
|
"videos": ("Asset Video Gallery", VIDEO_PAGE_SIZE),
|
|
"text": ("Asset Text Gallery", TEXT_PAGE_SIZE),
|
|
"fonts": ("Asset Font Gallery", TEXT_PAGE_SIZE),
|
|
}
|
|
page_names: list[str] = []
|
|
grouped_pages: dict[str, list[str]] = {}
|
|
for kind, rows in rows_by_kind.items():
|
|
title, page_size = settings[kind]
|
|
grouped_pages[kind] = []
|
|
for page_number, page_rows in enumerate(chunks(rows, page_size), start=1):
|
|
name = f"asset-{kind}-gallery-{page_number:02d}.md"
|
|
first = (page_number - 1) * page_size + 1
|
|
last = first + len(page_rows) - 1
|
|
lines = [
|
|
f"# {title} {page_number:02d}",
|
|
"",
|
|
f"Openable {kind} assets {first}-{last} of {len(rows)}.",
|
|
"",
|
|
"::gallery-start",
|
|
*[asset_directive(kind, row, first + idx) for idx, row in enumerate(page_rows)],
|
|
"::gallery-end",
|
|
]
|
|
grouped_pages[kind].append(write_page(name, "\n".join(lines)))
|
|
page_names.extend(grouped_pages[kind])
|
|
|
|
index_lines = [
|
|
"# Asset Previews",
|
|
"",
|
|
"Generated preview pages for openable reconstructed assets under `assets/openable/`.",
|
|
"",
|
|
"| Type | Count | Pages |",
|
|
"| --- | ---: | --- |",
|
|
]
|
|
for kind in ["images", "videos", "text", "fonts"]:
|
|
pages = grouped_pages[kind]
|
|
links = ", ".join(f"[{i + 1}]({name})" for i, name in enumerate(pages))
|
|
index_lines.append(f"| {kind} | `{len(rows_by_kind[kind])}` | {links} |")
|
|
write_page("asset-previews.md", "\n".join(index_lines))
|
|
return ["asset-previews.md", *page_names]
|
|
|
|
|
|
def build_radium_section3_bitmap_pages(rows: list[dict[str, str]]) -> list[str]:
|
|
if not RADIUM_SECTION3_BITMAP_MANIFEST.exists():
|
|
return []
|
|
with RADIUM_SECTION3_BITMAP_MANIFEST.open(newline="", encoding="utf-8") as f:
|
|
all_rows = list(csv.DictReader(f, delimiter="\t"))
|
|
status_counts: dict[str, int] = {}
|
|
for row in all_rows:
|
|
status_counts[row["decoder_status"]] = status_counts.get(row["decoder_status"], 0) + 1
|
|
status_lines = [f"- `{status}`: `{count}`" for status, count in sorted(status_counts.items())]
|
|
|
|
gallery_pages: list[str] = []
|
|
for page_number, page_rows in enumerate(chunks(rows, RADIUM_BITMAP_PAGE_SIZE), start=1):
|
|
name = f"radium-section3-bitmap-gallery-{page_number:02d}.md"
|
|
first = (page_number - 1) * RADIUM_BITMAP_PAGE_SIZE + 1
|
|
last = first + len(page_rows) - 1
|
|
lines = [
|
|
f"# Radium Section 3 Bitmap Gallery {page_number:02d}",
|
|
"",
|
|
f"Raw-indexed section 3 bitmap previews {first}-{last} of {len(rows)}.",
|
|
"",
|
|
"::gallery-start",
|
|
*[radium_section3_bitmap_directive(row) for row in page_rows],
|
|
"::gallery-end",
|
|
]
|
|
gallery_pages.append(write_page(name, "\n".join(lines)))
|
|
|
|
index_lines = [
|
|
"# Radium Section 3 Bitmap Previews",
|
|
"",
|
|
"These previews are decoded from `/games/pokemon_pro/image.bin` section 3 pointer-table records.",
|
|
"The current static decoder treats records as `uint32 id`, `uint32 flags`, `uint16 width`, `uint16 height`, followed by raw indexed pixel bytes when the payload is large enough for `width * height` pixels.",
|
|
"Pixel values are rendered as grayscale; `0xff` is rendered transparent. Compact payloads are retained in the manifest as undecoded rather than guessed.",
|
|
"",
|
|
"## Decoder Status Counts",
|
|
"",
|
|
*status_lines,
|
|
"",
|
|
"## Gallery Pages",
|
|
"",
|
|
page_nav(gallery_pages),
|
|
"",
|
|
"## Manifest",
|
|
"",
|
|
"- `analysis/radium-image/section3_bitmap_manifest.tsv`",
|
|
]
|
|
return [write_page("radium-section3-bitmaps.md", "\n".join(index_lines)), *gallery_pages]
|
|
|
|
|
|
def main() -> int:
|
|
sounds = sound_rows()
|
|
native_sounds = native_named_sound_rows()
|
|
sound_test_labels = native_sound_test_label_rows()
|
|
asr_draft_labels = asr_draft_sound_label_rows()
|
|
asr_review_labels = asr_review_sound_label_rows()
|
|
asr_catalog_labels = asr_catalog_sound_label_rows()
|
|
catalog_offset_candidates = catalog_offset_candidate_rows()
|
|
pokedex_voice_labels = pokedex_voice_label_rows()
|
|
spike_menu_sounds = spike_menu_sound_rows()
|
|
best_named_sounds = best_named_sound_rows()
|
|
assets = asset_rows()
|
|
radium_bitmaps = radium_section3_bitmap_rows()
|
|
pages = build_sound_pages(
|
|
sounds,
|
|
native_sounds,
|
|
sound_test_labels,
|
|
asr_draft_labels,
|
|
asr_review_labels,
|
|
asr_catalog_labels,
|
|
catalog_offset_candidates,
|
|
pokedex_voice_labels,
|
|
spike_menu_sounds,
|
|
best_named_sounds,
|
|
) + build_asset_pages(assets) + build_radium_section3_bitmap_pages(radium_bitmaps)
|
|
best_named_source_counts: dict[str, int] = {}
|
|
for row in best_named_sounds:
|
|
best_named_source_counts[row["name_source"]] = best_named_source_counts.get(row["name_source"], 0) + 1
|
|
summary = {
|
|
"sound_count": len(sounds),
|
|
"native_named_sound_count": len(native_sounds),
|
|
"native_sound_test_label_count": len(sound_test_labels),
|
|
"asr_draft_sound_label_count": len(asr_draft_labels),
|
|
"asr_review_sound_label_count": len(asr_review_labels),
|
|
"asr_catalog_sound_label_count": len(asr_catalog_labels),
|
|
"catalog_offset_candidate_count": len(catalog_offset_candidates),
|
|
"pokedex_voice_label_count": len(pokedex_voice_labels),
|
|
"spike_menu_sound_count": len(spike_menu_sounds),
|
|
"best_named_sound_count": len(best_named_sounds),
|
|
"unresolved_structural_sound_count": len(unresolved_best_named_rows(best_named_sounds)),
|
|
"best_named_source_counts": best_named_source_counts,
|
|
"asset_counts": {kind: len(rows) for kind, rows in assets.items()},
|
|
"radium_section3_bitmap_preview_count": len(radium_bitmaps),
|
|
"pages": pages,
|
|
}
|
|
OUT_MANIFEST.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
|
|
print(f"wrote {len(pages)} media preview pages")
|
|
print(json.dumps(summary, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|