nnnotes 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/voices.py ADDED
@@ -0,0 +1,1363 @@
1
+ """Voices: every character voice the master data names, joined to its cue in the cue sheet and to the decoded
2
+ streams of the export.
3
+
4
+ Sources (the rules: `voices` in viewrules.json, docs/views.md):
5
+
6
+ rows a master table whose rows name a MasterSound id (the voice collection's CharacterVoiceMasterType
7
+ tables, and MasterHomeSpot's intro voice): per row the character(s), the sound, the text, the row's
8
+ own category enum and the listed conditions (dates, unlock ranks)
9
+ code MasterTitle: the characters of `_voiceCharacterIds`, each with the cue names the client builds for
10
+ them (InitialVoicePlayer: title call and splash company voices)
11
+ Sound the MasterSound rows of the voice category that no other source names: their sheet and cue, and
12
+ what the sheet and cue names suggest (`inferred`: a situation, characters by name, the rows of other
13
+ sources in the same sheet), kept apart from the master facts
14
+ Story the ADV episodes of MasterAdv (`story` rules; storyvoices): per voice id of an episode command, the
15
+ command, its speaker and line texts (the episode's -Text shard), its sound (-Sound) and cue sheet
16
+ (-SoundCueSheet); the characters the speaker names suggest are `inferred`. The episodes, with their
17
+ kind (the table that plays them), title and status, are the document's `episodes`
18
+
19
+ A row's sound gives its cue sheet (MasterSoundCueSheet) and cue name. The cue is looked up in the sheet's ACB
20
+ (acb.cue_streams: cue -> waveforms -> the decoder's stream numbers, from the ACB's tables, no audio decoded) and
21
+ each stream in the result of the sheet's cri.audio task (its file, sha256, rate, channels, samples).
22
+
23
+ Content (source, characters, category, sound, text, conditions) and availability are kept apart: `availability`
24
+ says whether the master data resolves the sound to a sheet and cue (`master`), whether the catalog has the sheet's
25
+ key (`catalog`) and whether the export decoded the cue's streams (`exported`); `status` is the first that fails:
26
+
27
+ no-value the master data does not resolve the sound (no MasterSound row, no sheet row)
28
+ missing-key the catalog has no key Cri/Sound/<sheet>
29
+ not-exported the key exists, the export has no cri.audio result for it
30
+ unsupported the sheet's cri.audio task could not decode it (its reason in `detail`)
31
+ missing-cue the sheet is decoded, its ACB has no such cue, or the cue plays none of its streams
32
+ ok the cue's streams, in `audio`
33
+
34
+ Gaps are the rows that are missing-key or missing-cue. Reverse coverage: the catalog keys under the rules'
35
+ prefixes whose sheet no row names, and per decoded sheet the streams no row reaches. Whether a site shows a voice
36
+ is not recorded here: sites are built separately.
37
+
38
+ The stage `view.voices` (one task per catalog subject, when master data is set and the view selected) reads the
39
+ master tables of its rules, the rules, the part of the address table it looks up ("addresses", views.Recorder), the
40
+ sheets it resolves ("sheets": sheet -> cri.audio task and ACB content), each decoded sheet's ACB ("acb:<sha256>"),
41
+ the episode objects the unity.export results hold ("episodes": asset -> part -> object, artifact and content id;
42
+ "adv:<sha256>" each content) and, in its context, the keys of the cri.audio results. Its document
43
+ (nnnotes.voices/1) is the artifact "view.voices:<subject>#view"; the `original` layout gets views/voices.json with
44
+ the file of every stream.
45
+
46
+ The command `nnnotes voices` lists, searches and fetches voices from that document or, without an export, from an
47
+ index built from the configured master data and catalog (content only; the episodes named by --episode or a row id
48
+ are read from the catalog). Nothing here imports UnityPy or numpy before a sheet is decoded or an episode loaded.
49
+ """
50
+ from __future__ import annotations
51
+
52
+ import copy
53
+ import functools
54
+ import json
55
+ import re
56
+ import shutil
57
+ import sys
58
+ import tempfile
59
+ import unicodedata
60
+ from pathlib import Path
61
+
62
+ from . import acb, advcommand, contract, languages, storyvoices, views
63
+ from .contract import Cost, Input
64
+ from .stages import Output, Pending, Stage
65
+
66
+ SCHEMA = "nnnotes.voices/1"
67
+ VIEW = "voices"
68
+ STAGE = views.STAGE_PREFIX + VIEW
69
+ AUDIO_STAGE = "cri.audio"
70
+ EXPORT_STAGE = "unity.export"
71
+ SHEET_PREFIX = "Cri/Sound/"
72
+ STATUSES = ("ok", "missing-cue", "missing-key", "not-exported", "unsupported", "no-value")
73
+ GAP_STATUSES = ("missing-key", "missing-cue")
74
+ SOUND_SOURCE = "Sound"
75
+ STORY_SOURCE = "Story"
76
+ SPEAKER_RULE = "speaker-name"
77
+ LANGUAGES = tuple(languages.LANGUAGES)
78
+ _SOURCE_KEYS = {"name", "type", "table", "category", "voices", "names", "conditions", "characters", "sheet", "cues"}
79
+ _VOICE_KEYS = {"slot", "character", "characters", "sound", "text"}
80
+ _RULE_KEYS = {"version", "key", "prefixes", "sound", "characters", "enums", "sources", "situations", "near", "story"}
81
+ _STORY_KEYS = {"table", "asset", "title", "key", "shards", "split", "kinds"}
82
+ _KIND_KEYS = {"kind", "table", "adv", "fields", "names"}
83
+ # story part -> (the class of its object, the unity.export artifact role that holds its content)
84
+ STORY_OBJECTS = {"script": ("MonoBehaviour", "json"), "text": ("TextAsset", "data"), "sound": ("TextAsset", "data"),
85
+ "sheets": ("TextAsset", "data")}
86
+
87
+
88
+ # ---------------------------------------------------------------- rules
89
+ def check_rules(rules: dict) -> None:
90
+ """Raise views.ViewError naming the first problem of the `voices` rules."""
91
+ v = rules.get(VIEW)
92
+ if not isinstance(v, dict):
93
+ raise views.ViewError("view rules: no voices rules")
94
+ if set(v) - _RULE_KEYS:
95
+ raise views.ViewError(f"voices: unknown keys {sorted(set(v) - _RULE_KEYS)}")
96
+ if not isinstance(v.get("version"), int) or v["version"] < 1:
97
+ raise views.ViewError("voices: version is a positive integer")
98
+ if views.fields(v.get("key", "")) != ["sheet"]:
99
+ raise views.ViewError("voices: key is a template of {sheet}")
100
+ for k in ("table", "sheets", "sheet", "sheetName", "cue", "category", "voice"):
101
+ if k not in v.get("sound", {}):
102
+ raise views.ViewError(f"voices.sound: no {k}")
103
+ if "table" not in v.get("characters", {}) or "token" not in v["characters"]:
104
+ raise views.ViewError("voices.characters: a table and a token column")
105
+ enums = v.get("enums", {})
106
+ names = set()
107
+ for s in v.get("sources", []):
108
+ where = f"voices.sources.{s.get('name')}"
109
+ if set(s) - _SOURCE_KEYS or not s.get("name") or not s.get("table"):
110
+ raise views.ViewError(f"{where}: keys are {', '.join(sorted(_SOURCE_KEYS))} (name and table needed)")
111
+ if s["name"] in names or s["name"] in (SOUND_SOURCE, STORY_SOURCE):
112
+ raise views.ViewError(f"{where}: repeated or reserved name")
113
+ names.add(s["name"])
114
+ cat = s.get("category")
115
+ if cat is not None and (set(cat) != {"column", "enum"} or cat["enum"] not in enums):
116
+ raise views.ViewError(f"{where}.category: {{column, enum}} of a known enum")
117
+ if ("voices" in s) == ("cues" in s):
118
+ raise views.ViewError(f"{where}: either voices or cues")
119
+ for x in s.get("voices", []):
120
+ if set(x) - _VOICE_KEYS or "sound" not in x or ("character" in x) == ("characters" in x):
121
+ raise views.ViewError(f"{where}.voices: a sound and a character or characters column")
122
+ if len(s.get("voices", [])) > 1 and len({x.get("slot") for x in s["voices"]}) != len(s["voices"]):
123
+ raise views.ViewError(f"{where}.voices: several voices of a row need distinct slots")
124
+ if "cues" in s:
125
+ if not s.get("characters") or not s.get("sheet"):
126
+ raise views.ViewError(f"{where}: cues need a characters column and a sheet")
127
+ for c in s["cues"]:
128
+ if set(c) != {"category", "cue"} or views.fields(c["cue"]) != ["character"]:
129
+ raise views.ViewError(f"{where}.cues: {{category, cue}}, the cue a template of {{character}}")
130
+ for n in v.get("near", []):
131
+ if n not in names:
132
+ raise views.ViewError(f"voices.near: {n!r} is not a source")
133
+ for s in v.get("situations", []):
134
+ if set(s) != {"situation", "cue"}:
135
+ raise views.ViewError("voices.situations: {situation, cue}")
136
+ re.compile(s["cue"])
137
+ for p in v.get("prefixes", []):
138
+ if not isinstance(p, str) or not p:
139
+ raise views.ViewError("voices.prefixes: non-empty strings")
140
+ if "story" in v:
141
+ _check_story(v["story"])
142
+
143
+
144
+ def _check_story(st) -> None:
145
+ if not isinstance(st, dict) or set(st) - _STORY_KEYS or not all(st.get(k) for k in ("table", "asset", "key")):
146
+ raise views.ViewError(f"voices.story: keys are {', '.join(sorted(_STORY_KEYS))} (table, asset and key "
147
+ f"needed)")
148
+ if set(views.fields(st["key"])) != {"asset"}:
149
+ raise views.ViewError("voices.story: key is a template of {asset}")
150
+ shards = st.get("shards")
151
+ if not isinstance(shards, dict) or set(shards) != {"text", "sound", "sheets"} or not all(
152
+ isinstance(x, str) and x for x in shards.values()):
153
+ raise views.ViewError("voices.story.shards: the key suffixes of text, sound and sheets")
154
+ if not isinstance(st.get("split", ""), str):
155
+ raise views.ViewError("voices.story.split: a string")
156
+ kinds = set()
157
+ for k in st.get("kinds", []):
158
+ where = f"voices.story.kinds.{k.get('kind') if isinstance(k, dict) else k}"
159
+ if not isinstance(k, dict) or set(k) - _KIND_KEYS or not all(k.get(x) for x in ("kind", "table", "adv")):
160
+ raise views.ViewError(f"{where}: keys are {', '.join(sorted(_KIND_KEYS))} (kind, table and adv needed)")
161
+ if k["kind"] in kinds:
162
+ raise views.ViewError(f"{where}: repeated kind")
163
+ kinds.add(k["kind"])
164
+ for part in ("fields", "names"):
165
+ for label, spec in (k.get(part) or {}).items():
166
+ views._check_var(f"{where}.{part}.{label}", spec)
167
+
168
+
169
+ def load_rules(path=None) -> dict:
170
+ """{"format", "voices"} of the view rules (the package's viewrules.json by default), checked."""
171
+ rules = views.load_rules(path)
172
+ out = {"format": rules["format"], VIEW: rules.get(VIEW)}
173
+ check_rules(out)
174
+ return out
175
+
176
+
177
+ def rules_digest(rules: dict) -> str:
178
+ return contract.digest({"format": rules["format"], VIEW: rules[VIEW]})
179
+
180
+
181
+ def tables_of(rules: dict) -> list[str]:
182
+ """Every master table the voices rules read, sorted."""
183
+ v = rules[VIEW]
184
+ out = {v["sound"]["table"], v["sound"]["sheets"], v["characters"]["table"], views.TEXT_TABLE}
185
+ out.update(s["table"] for s in v["sources"])
186
+ st = v.get("story")
187
+ if st:
188
+ out.add(st["table"])
189
+ for k in st.get("kinds", []):
190
+ out.add(k["table"])
191
+ for spec in list((k.get("fields") or {}).values()) + list((k.get("names") or {}).values()):
192
+ out.update(step.partition(".")[0] for step in views._chain(spec)[1:])
193
+ return sorted(out)
194
+
195
+
196
+ def story_keys(rules: dict, asset: str) -> dict:
197
+ """{part: catalog key} of an episode asset (storyvoices.PARTS)."""
198
+ return _story_keys(rules[VIEW]["story"], asset)
199
+
200
+
201
+ def _story_keys(st: dict, asset: str) -> dict:
202
+ key = st["key"].format(asset=asset)
203
+ return {"script": key, **{p: key + st["shards"][p] for p in ("text", "sound", "sheets")}}
204
+
205
+
206
+ def story_assets(rules: dict, tables) -> list[str]:
207
+ """The episode assets of the story table's rows (sorted by row id; an empty asset names none)."""
208
+ st = rules[VIEW].get("story")
209
+ if not st:
210
+ return []
211
+ rows = views._Lookup(tables).rows(st["table"])
212
+ out = []
213
+ for r in sorted(rows, key=lambda r: views._sort_key(r.get("_id"))):
214
+ a = r.get(st["asset"])
215
+ if isinstance(a, str) and a and a not in out:
216
+ out.append(a)
217
+ return out
218
+
219
+
220
+ # ---------------------------------------------------------------- audio of the decoded sheets
221
+ def sheet_audio(result: dict | None, cues: dict | None) -> dict:
222
+ """What the index reads of one decoded sheet: its cri.audio result (streams by number: artifact id, file, sha256,
223
+ size and the stream facts; status and reason) and its ACB's cue table (acb.cue_streams)."""
224
+ if result is None:
225
+ return {"status": "not-exported", "streams": {}, "cues": {}}
226
+ streams = {}
227
+ for a in result["artifacts"]:
228
+ if a["semantics"].get("kind") != "audio.stream":
229
+ continue
230
+ f = dict(a["semantics"].get("facts") or {})
231
+ n = f.pop("stream", None)
232
+ if n is None:
233
+ continue
234
+ streams[int(n)] = {"artifact": a["id"], "file": contract.parse_artifact_id(a["id"])[1],
235
+ "sha256": a["content"]["sha256"], "size": a["content"]["size"], **f}
236
+ item = next(iter(result["items"]), {})
237
+ out = {"status": "unsupported" if item.get("status") == "unsupported" else "exported",
238
+ "streams": streams, "cues": cues or {}}
239
+ if out["status"] == "unsupported":
240
+ out["why"] = (item.get("reason") or {}).get("code")
241
+ return out
242
+
243
+
244
+ def _stream_entry(s: dict) -> dict:
245
+ e = {k: s[k] for k in ("artifact", "file", "sha256", "size", "sampleRate", "channels", "samples", "loopStart",
246
+ "loopEnd") if k in s}
247
+ if s.get("samples") and s.get("sampleRate"):
248
+ e["seconds"] = round(s["samples"] / s["sampleRate"], 6)
249
+ return e
250
+
251
+
252
+ # ---------------------------------------------------------------- the index
253
+ class _Master:
254
+ def __init__(self, rules: dict, tables):
255
+ self.v = rules[VIEW]
256
+ self.look = views._Lookup(tables)
257
+ sd = self.v["sound"]
258
+ self.sound = {r.get("_id"): r for r in self.look.rows(sd["table"])}
259
+ self.sheets = {r.get("_id"): r.get(sd["sheetName"]) for r in self.look.rows(sd["sheets"])}
260
+ cd = self.v["characters"]
261
+ self.characters = {}
262
+ ids = {}
263
+ for r in sorted(self.look.rows(cd["table"]), key=lambda r: views._sort_key(r.get("_id"))):
264
+ names = {k: self.look.text(r.get(col)) for k, col in cd.get("names", {}).items()
265
+ if not views._empty(r.get(col))}
266
+ ids[r.get("_id")] = str(r.get(cd["token"]) or "")
267
+ token = ids[r.get("_id")].rsplit("_", 1)[-1]
268
+ self.characters[r.get("_id")] = {"id": r.get("_id"), "names": names, "token": token}
269
+ self.name_parts = _name_parts(ids)
270
+
271
+ def enum(self, name: str, value):
272
+ return self.v["enums"].get(name, {}).get(str(value))
273
+
274
+ def text(self, tid):
275
+ if views._empty(tid):
276
+ return None
277
+ return {"id": tid, "texts": self.look.text(tid)}
278
+
279
+
280
+ def _name_parts(ids: dict) -> dict:
281
+ """{name part, case folded: character}: the `_` parts of each character's token column (`_nameTextID`) after
282
+ the leading parts every character's value shares (its last part always kept); a part of several characters is
283
+ left out."""
284
+ split = {c: [p for p in v.split("_") if p] for c, v in ids.items()}
285
+ lists = [v for v in split.values() if v]
286
+ head = 0
287
+ while lists and head < min(len(v) for v in lists) - 1 and len({v[head] for v in lists}) == 1:
288
+ head += 1
289
+ owners: dict = {}
290
+ for c, parts in split.items():
291
+ for p in parts[head:]:
292
+ owners.setdefault(p.casefold(), set()).add(c)
293
+ return {p: next(iter(cs)) for p, cs in owners.items() if len(cs) == 1}
294
+
295
+
296
+ class _Resolver:
297
+ """The status, availability and audio of a (sheet, cue); records the catalog keys it looks up."""
298
+
299
+ def __init__(self, rules: dict, index, audio: dict):
300
+ self.key = rules[VIEW]["key"]
301
+ self.index, self.audio = index, audio
302
+ self.reached: dict[str, set] = {}
303
+
304
+ def __call__(self, sheet, cue) -> dict:
305
+ out = {"availability": {"master": sheet is not None and cue is not None, "catalog": None,
306
+ "exported": False}}
307
+ if sheet is None or cue is None:
308
+ return out
309
+ present = self.index.objects(self.key.format(sheet=sheet)) is not None
310
+ out["availability"]["catalog"] = present
311
+ if not present:
312
+ out.update(status="missing-key", detail=f"no catalog key {self.key.format(sheet=sheet)}")
313
+ return out
314
+ a = self.audio.get(sheet)
315
+ if a is None or a["status"] == "not-exported":
316
+ out["status"] = "not-exported"
317
+ return out
318
+ if a["status"] == "unsupported":
319
+ out.update(status="unsupported", detail=a.get("why"))
320
+ return out
321
+ c = a["cues"].get(cue)
322
+ if c is None:
323
+ out.update(status="missing-cue", detail=f"sheet {sheet} has no cue {cue}")
324
+ return out
325
+ streams, problems = [], []
326
+ for w in c["streams"]:
327
+ s = a["streams"].get(w.get("stream"))
328
+ if s is None:
329
+ problems.append(f"waveform {w.get('awbId')} is not a decoded stream")
330
+ continue
331
+ if abs((s.get("samples") or 0) - (w.get("samples") or 0)) > 8 or s.get("sampleRate") != w.get(
332
+ "sampleRate") or s.get("channels") != w.get("channels"):
333
+ problems.append(f"stream {w['stream']}: the ACB's waveform differs from the decoded stream")
334
+ streams.append({"stream": w["stream"], **_stream_entry(s)})
335
+ self.reached.setdefault(sheet, set()).add(w["stream"])
336
+ if not streams:
337
+ out.update(status="missing-cue", detail=f"cue {cue} plays no stream of sheet {sheet}")
338
+ return out
339
+ out["availability"]["exported"] = True
340
+ out.update(status="ok", audio={"cueId": c.get("cueId"), "lengthMs": c.get("lengthMs"), "streams": streams})
341
+ if problems:
342
+ out["detail"] = "; ".join(problems)
343
+ return out
344
+
345
+
346
+ def _sound_fields(m: _Master, sid) -> tuple[dict, str | None, str | None, str | None]:
347
+ """(sound entry, sheet, cue, why the master data does not resolve it)."""
348
+ sd = m.v["sound"]
349
+ snd = m.sound.get(sid)
350
+ if snd is None:
351
+ return {"id": sid}, None, None, f"{sd['table']} has no row {sid}"
352
+ sheet = m.sheets.get(snd.get(sd["sheet"]))
353
+ entry = {"id": sid, "category": snd.get(sd["category"]), "sheet": sheet, "cue": snd.get(sd["cue"])}
354
+ if sheet is None:
355
+ return entry, None, None, f"{sd['sheets']} has no row {snd.get(sd['sheet'])}"
356
+ return entry, sheet, snd.get(sd["cue"]), None
357
+
358
+
359
+ def _finish(row: dict, res: dict, why: str | None) -> dict:
360
+ row["availability"] = res["availability"]
361
+ if why is not None:
362
+ row.update(status="no-value", detail=why)
363
+ return row
364
+ row["status"] = res["status"]
365
+ for k in ("detail", "audio"):
366
+ if k in res:
367
+ row[k] = res[k]
368
+ return row
369
+
370
+
371
+ def _category(m: _Master, s: dict, raw: dict) -> dict:
372
+ cat = s.get("category")
373
+ if cat is None:
374
+ return {"name": s["name"]}
375
+ value = raw.get(cat["column"])
376
+ return {"name": m.enum(cat["enum"], value), "enum": cat["enum"], "value": value}
377
+
378
+
379
+ def _characters(value) -> list:
380
+ vals = value if isinstance(value, list) else ([] if views._empty(value) else [value])
381
+ return [v for v in vals if not views._empty(v) and v != 0]
382
+
383
+
384
+ def _row_sources(m: _Master, resolve: _Resolver, named: set) -> list[dict]:
385
+ out = []
386
+ for s in m.v["sources"]:
387
+ if "voices" not in s:
388
+ continue
389
+ stype = s.get("type")
390
+ for raw in sorted(m.look.rows(s["table"]), key=lambda r: views._sort_key(r.get("_id"))):
391
+ for x in s["voices"]:
392
+ sid = raw.get(x["sound"])
393
+ if views._empty(sid) or sid == 0:
394
+ continue
395
+ named.add(sid)
396
+ rid = f"{s['table']}:{raw.get('_id')}" + (f":{x['slot']}" if len(s["voices"]) > 1 else "")
397
+ source = {"name": s["name"], "table": s["table"], "row": raw.get("_id"), "column": x["sound"]}
398
+ if stype is not None:
399
+ source["type"] = {"enum": "CharacterVoiceMasterType", "value": stype,
400
+ "name": m.enum("CharacterVoiceMasterType", stype)}
401
+ if len(s["voices"]) > 1:
402
+ source["slot"] = x["slot"]
403
+ chars = _characters(raw.get(x["character"] if "character" in x else x["characters"]))
404
+ sound, sheet, cue, why = _sound_fields(m, sid)
405
+ row = {"id": rid, "source": source, "characters": chars, "category": _category(m, s, raw),
406
+ "sound": sound}
407
+ text = m.text(raw.get(x["text"])) if "text" in x else None
408
+ if text is not None:
409
+ row["text"] = text
410
+ names = {k: m.look.text(raw.get(col)) for k, col in s.get("names", {}).items()
411
+ if not views._empty(raw.get(col))}
412
+ if names:
413
+ row["names"] = names
414
+ cond = {c: raw[c] for c in s.get("conditions", []) if c in raw and not views._empty(raw[c])}
415
+ if cond:
416
+ row["conditions"] = cond
417
+ out.append(_finish(row, resolve(sheet, cue), why))
418
+ return out
419
+
420
+
421
+ def _code_sources(m: _Master, resolve: _Resolver) -> list[dict]:
422
+ out = []
423
+ for s in m.v["sources"]:
424
+ if "cues" not in s:
425
+ continue
426
+ by_char: dict = {}
427
+ for raw in sorted(m.look.rows(s["table"]), key=lambda r: views._sort_key(r.get("_id"))):
428
+ for c in _characters(raw.get(s["characters"])):
429
+ by_char.setdefault(c, []).append(raw.get("_id"))
430
+ for c in sorted(by_char, key=views._sort_key):
431
+ for pat in s["cues"]:
432
+ cue = pat["cue"].format(character=c)
433
+ row = {"id": f"{s['name']}:{c}:{pat['category']}",
434
+ "source": {"name": s["name"], "table": s["table"], "rows": by_char[c], "column": s["characters"],
435
+ "cue": pat["cue"]},
436
+ "characters": [c], "category": {"name": pat["category"]},
437
+ "sound": {"id": None, "sheet": s["sheet"], "cue": cue}}
438
+ out.append(_finish(row, resolve(s["sheet"], cue), None))
439
+ return out
440
+
441
+
442
+ def _inferred(m: _Master, sheet: str | None, cue: str, near: dict) -> dict:
443
+ out = {}
444
+ for s in m.v.get("situations", []):
445
+ if re.fullmatch(s["cue"], cue or ""):
446
+ out["situation"] = s["situation"]
447
+ break
448
+ tokens = [t.casefold() for t in re.split(r"[_\W]+", cue or "") if t]
449
+ chars = []
450
+ for t in tokens:
451
+ for c in m.characters.values():
452
+ if c["token"] and c["token"].casefold() == t and c["id"] not in chars:
453
+ chars.append(c["id"])
454
+ if chars:
455
+ out["characters"] = chars
456
+ rows = near.get(sheet)
457
+ if rows:
458
+ out["rows"] = sorted(rows)
459
+ return out
460
+
461
+
462
+ def _sound_rows(m: _Master, resolve: _Resolver, named: set, rows: list[dict]) -> list[dict]:
463
+ sd = m.v["sound"]
464
+ near: dict[str, set] = {}
465
+ wanted = set(m.v.get("near", []))
466
+ for r in rows:
467
+ if r["source"]["name"] in wanted and r["sound"].get("sheet"):
468
+ near.setdefault(r["sound"]["sheet"], set()).add(r["id"])
469
+ out = []
470
+ for sid in sorted((k for k, r in m.sound.items() if r.get(sd["category"]) == sd["voice"] and k not in named),
471
+ key=views._sort_key):
472
+ sound, sheet, cue, why = _sound_fields(m, sid)
473
+ row = {"id": f"{sd['table']}:{sid}", "source": {"name": SOUND_SOURCE, "table": sd["table"], "row": sid},
474
+ "characters": [], "category": {"name": None}, "sound": sound}
475
+ inf = _inferred(m, sheet, sound.get("cue"), near)
476
+ if inf:
477
+ row["inferred"] = inf
478
+ out.append(_finish(row, resolve(sheet, cue), why))
479
+ return out
480
+
481
+
482
+ def _kinds(m: _Master) -> dict:
483
+ """{adv id: (kind rule, row)}: the first row (kinds in rules order, rows by id) that plays the episode."""
484
+ out: dict = {}
485
+ for k in m.v["story"].get("kinds", []):
486
+ for raw in sorted(m.look.rows(k["table"]), key=lambda r: views._sort_key(r.get("_id"))):
487
+ aid = raw.get(k["adv"])
488
+ if not views._empty(aid) and aid != 0:
489
+ out.setdefault(aid, (k, raw))
490
+ return out
491
+
492
+
493
+ def _episode(m: _Master, raw: dict, kinds: dict) -> dict:
494
+ st = m.v["story"]
495
+ ep = {"id": raw.get("_id"), "asset": raw.get(st["asset"])}
496
+ title = m.text(raw.get(st["title"])) if st.get("title") else None
497
+ if title is not None:
498
+ ep["title"] = title
499
+ rule, row = kinds.get(ep["id"], (None, None))
500
+ ep["kind"] = rule["kind"] if rule else None
501
+ if rule:
502
+ ep["table"], ep["row"] = rule["table"], row.get("_id")
503
+ r = views._Row(row, m.look)
504
+ fields = {}
505
+ for label, spec in (rule.get("fields") or {}).items():
506
+ value, _ = r.value(spec)
507
+ if not views._empty(value):
508
+ fields[label] = value
509
+ if fields:
510
+ ep["fields"] = fields
511
+ names = views._names(r, rule.get("names") or {}, m.look)
512
+ if names:
513
+ ep["names"] = names
514
+ return ep
515
+
516
+
517
+ def _speaker_matches(m: _Master, target: str) -> list[dict]:
518
+ """The segments of a command's TargetName (split at the rules' `split`) that are, ignoring case, a name part of
519
+ one character (_name_parts: its token or another part of its name id), with that character (rule SPEAKER_RULE),
520
+ in order, each character once."""
521
+ split = m.v["story"].get("split") or ""
522
+ out = []
523
+ for part in (target.split(split) if split else [target]):
524
+ seg = part.strip()
525
+ c = m.name_parts.get(seg.casefold()) if seg else None
526
+ if c is not None and all(x["character"] != c for x in out):
527
+ out.append({"segment": seg, "character": c})
528
+ return out
529
+
530
+
531
+ def _unresolved(status: str, detail: str) -> dict:
532
+ return {"availability": {"master": False, "catalog": None, "exported": False}, "status": status,
533
+ "detail": detail}
534
+
535
+
536
+ def _story_rows(m: _Master, resolve: _Resolver, index, story: dict) -> tuple[list[dict], list[dict]]:
537
+ """(episodes, rows) of the story source: every row of the story table, and a row per voice id of each command
538
+ of the exported episodes (`story`: {asset: storyvoices.episode(...)})."""
539
+ st, sd = m.v["story"], m.v["sound"]
540
+ kinds = _kinds(m)
541
+ episodes, rows = [], []
542
+ for raw in sorted(m.look.rows(st["table"]), key=lambda r: views._sort_key(r.get("_id"))):
543
+ ep = _episode(m, raw, kinds)
544
+ episodes.append(ep)
545
+ aid, asset = ep["id"], ep["asset"]
546
+ ep["voices"] = 0
547
+ if not isinstance(asset, str) or not asset:
548
+ ep.update(status="no-value", detail=f"{st['table']} row {aid} has no {st['asset']}")
549
+ continue
550
+ keys = _story_keys(st, asset)
551
+ present = {p: index.objects(k) is not None for p, k in keys.items()}
552
+ data = story.get(asset) or {}
553
+ if not present["script"]:
554
+ ep.update(status="missing-key", detail=f"no catalog key {keys['script']}")
555
+ continue
556
+ if "why" in data:
557
+ ep.update(status="unsupported", detail=data["why"])
558
+ continue
559
+ if data.get("commands") is None:
560
+ ep["status"] = "not-exported"
561
+ continue
562
+ ep["status"] = "ok"
563
+
564
+ def shard(part):
565
+ if data.get(part) is not None:
566
+ return data[part], None
567
+ if not present[part]:
568
+ return None, _unresolved("missing-key", f"no catalog key {keys[part]}")
569
+ return None, _unresolved("not-exported", f"{keys[part]} is not exported")
570
+
571
+ texts, _ = shard("text")
572
+ sounds, sound_gap = shard("sound")
573
+ sheets, sheet_gap = shard("sheets")
574
+
575
+ def text_of(tid):
576
+ return {"id": tid, "texts": languages.texts(texts[tid]) if texts is not None and tid in texts else None}
577
+
578
+ for c in data["commands"]:
579
+ vids = [v for v in c.get("VoiceIDs") or () if not views._empty(v)]
580
+ for n, vid in enumerate(vids, 1):
581
+ source = {"name": STORY_SOURCE, "table": st["table"], "row": aid, "command": c.get("Index"),
582
+ "type": {"enum": "AdvCommand", "value": c.get("Command"),
583
+ "name": advcommand.name(c.get("Command"))}}
584
+ if len(vids) > 1:
585
+ source["slot"] = str(n)
586
+ if c.get("IgnoreData"):
587
+ source["ignoreData"] = True
588
+ row = {"id": f"{st['table']}:{aid}:{c.get('Index')}" + (f":{n}" if len(vids) > 1 else ""),
589
+ "source": source, "characters": [], "category": {"name": ep["kind"]}, "sound": {"id": vid}}
590
+ if not views._empty(c.get("AdvTextID")):
591
+ row["text"] = text_of(c["AdvTextID"])
592
+ target, tids = c.get("TargetName") or "", list(c.get("TargetTextIDs") or ())
593
+ if target or tids:
594
+ row["speaker"] = {"name": target, "texts": [text_of(t) for t in tids]}
595
+ matches = _speaker_matches(m, target)
596
+ if matches:
597
+ row["inferred"] = {"characters": [x["character"] for x in matches], "rule": SPEAKER_RULE,
598
+ "matches": matches}
599
+ res, why = sound_gap, None
600
+ if res is None:
601
+ s = sounds.get(vid)
602
+ if s is None:
603
+ why = f"{keys['sound']} has no row {vid}"
604
+ else:
605
+ cue = s.get(sd["cue"]) or None
606
+ row["sound"].update(category=s.get(sd["category"]), sheet=None, cue=cue)
607
+ res = sheet_gap
608
+ if res is None:
609
+ sh = sheets.get(s.get(sd["sheet"]))
610
+ if sh is None:
611
+ why = f"{keys['sheets']} has no row {s.get(sd['sheet'])}"
612
+ elif not sh.get(sd["sheetName"]) or cue is None:
613
+ why = f"{keys['sound']} row {vid}: no cue sheet or cue name"
614
+ else:
615
+ row["sound"]["sheet"] = sh[sd["sheetName"]]
616
+ res = resolve(row["sound"]["sheet"], cue)
617
+ rows.append(_finish(row, res if res is not None else resolve(None, None), why))
618
+ ep["voices"] += 1
619
+ return episodes, rows
620
+
621
+
622
+ def _coverage(v: dict, rows: list[dict], index, audio: dict, reached: dict, empty: dict,
623
+ episodes: list[dict] | None = None) -> dict:
624
+ sources = {}
625
+ for s in [x["name"] for x in v["sources"]] + [SOUND_SOURCE] + ([STORY_SOURCE] if "story" in v else []):
626
+ sources[s] = {"rows": 0, "counts": {}, "gaps": []}
627
+ if s in empty:
628
+ sources[s]["empty"] = empty[s]
629
+ if "story" in v:
630
+ eps = episodes or []
631
+ counts: dict = {}
632
+ for e in eps:
633
+ counts[e["status"]] = counts.get(e["status"], 0) + 1
634
+ sources[STORY_SOURCE]["empty"] = sum(1 for e in eps if e["status"] == "ok" and not e["voices"])
635
+ sources[STORY_SOURCE]["episodes"] = {
636
+ "count": len(eps), "counts": dict(sorted(counts.items())),
637
+ "gaps": [f"{v['story']['table']}:{e['id']}" for e in eps if e["status"] in GAP_STATUSES]}
638
+ for r in rows:
639
+ c = sources[r["source"]["name"]]
640
+ c["rows"] += 1
641
+ c["counts"][r["status"]] = c["counts"].get(r["status"], 0) + 1
642
+ if r["status"] in GAP_STATUSES:
643
+ c["gaps"].append(r["id"])
644
+ for c in sources.values():
645
+ c["counts"] = dict(sorted(c["counts"].items()))
646
+ key = v["key"]
647
+ head = key.split("{", 1)[0]
648
+ keys = sorted({k for p in v.get("prefixes", []) for k in index.keys(p)})
649
+ named = {r["sound"]["sheet"] for r in rows if r["sound"].get("sheet")}
650
+ loose = [k for k in keys if k.startswith(head) and k[len(head):] not in named]
651
+ sheets = sorted({k[len(head):] for k in keys if k.startswith(head)} | named)
652
+ streams, total = {}, 0
653
+ for sh in sheets:
654
+ a = audio.get(sh)
655
+ if a is None or a["status"] != "exported":
656
+ continue
657
+ total += len(a["streams"])
658
+ left = [[n, a["streams"][n].get("name")] for n in sorted(a["streams"]) if n not in reached.get(sh, ())]
659
+ if left:
660
+ streams[sh] = left
661
+ return {"rows": len(rows), "sources": sources,
662
+ "reverse": {"prefixes": list(v.get("prefixes", [])), "keys": len(keys), "unreferencedKeys": loose,
663
+ "streams": total, "unreferencedStreams": streams}}
664
+
665
+
666
+ def build(rules: dict, tables, index, audio: dict | None = None, snapshot: dict | None = None,
667
+ story: dict | None = None) -> dict:
668
+ """The voices document (nnnotes.voices/1) of master `tables` (views.Tables), the address `index` (catalog
669
+ keys; wrap it in a views.Recorder to learn what it read), `audio` ({sheet: sheet_audio(...)} of the decoded
670
+ sheets; none: nothing exported) and `story` ({episode asset: storyvoices.episode(...)} of the exported
671
+ episodes). `snapshot`: {"tables": {table: sha256}, "sheets": {...}, ...} to record."""
672
+ check_rules(rules)
673
+ audio = audio or {}
674
+ m = _Master(rules, tables)
675
+ resolve = _Resolver(rules, index, audio)
676
+ named: set = set()
677
+ rows = _row_sources(m, resolve, named)
678
+ rows += _code_sources(m, resolve)
679
+ empty = {}
680
+ for s in m.v["sources"]:
681
+ if "voices" in s:
682
+ empty[s["name"]] = sum(1 for raw in m.look.rows(s["table"]) for x in s["voices"]
683
+ if views._empty(raw.get(x["sound"])) or raw.get(x["sound"]) == 0)
684
+ rows += _sound_rows(m, resolve, named, rows)
685
+ episodes = None
686
+ if "story" in m.v:
687
+ episodes, story_rows = _story_rows(m, resolve, index, story or {})
688
+ rows += story_rows
689
+ used = sorted({c for r in rows for c in r["characters"] + r.get("inferred", {}).get("characters", [])},
690
+ key=views._sort_key)
691
+ doc = {"schema": SCHEMA, "view": VIEW, "version": rules[VIEW]["version"], "rules": rules_digest(rules),
692
+ "tables": tables_of(rules),
693
+ "characters": [m.characters[c] for c in used if c in m.characters],
694
+ "rows": rows}
695
+ if episodes is not None:
696
+ doc["episodes"] = episodes
697
+ if snapshot:
698
+ doc["snapshot"] = snapshot
699
+ doc["coverage"] = _coverage(m.v, rows, index, audio, resolve.reached, empty, episodes)
700
+ return doc
701
+
702
+
703
+ def gaps(doc: dict) -> int:
704
+ """The gap rows of every source and the story episodes whose key is missing."""
705
+ return sum(len(c["gaps"]) + len(c.get("episodes", {}).get("gaps", ())) for c in doc["coverage"]["sources"].values())
706
+
707
+
708
+ def counts(doc: dict) -> dict:
709
+ out: dict = {}
710
+ for r in doc["rows"]:
711
+ out[r["status"]] = out.get(r["status"], 0) + 1
712
+ return dict(sorted(out.items()))
713
+
714
+
715
+ def unreferenced_streams(doc: dict) -> int:
716
+ return sum(len(v) for v in doc["coverage"]["reverse"]["unreferencedStreams"].values())
717
+
718
+
719
+ def with_paths(doc: dict, paths) -> dict:
720
+ """A copy of a voices document with the layout path of every stream (`paths.artifact(artifact id)`; null when
721
+ the layout places none)."""
722
+ out = copy.deepcopy(doc)
723
+ for r in out["rows"]:
724
+ for s in (r.get("audio") or {}).get("streams", []):
725
+ s["path"] = paths.artifact(s["artifact"])
726
+ out["layout"] = "original"
727
+ return out
728
+
729
+
730
+ # ---------------------------------------------------------------- the stage
731
+ def _sheet_name(name: str) -> str | None:
732
+ if name.startswith(SHEET_PREFIX):
733
+ return name[len(SHEET_PREFIX):] or None
734
+ return name if "/" not in name else None
735
+
736
+
737
+ _address_cache: dict = {} # content id of an address table -> (AddressIndex, files), the latest one
738
+
739
+
740
+ def _address_parts(env, tid: str) -> tuple:
741
+ """(AddressIndex, {serialized file: stable bundle name}) of the result of the link.addresses task `tid`."""
742
+ sha = env.artifact(tid, "addresses")["content"]["sha256"]
743
+ hit = _address_cache.get(sha)
744
+ if hit is None:
745
+ doc = contract.loads(env.store.read(sha))
746
+ hit = (views.AddressIndex.from_addresses(doc), dict(doc.get("files") or {}))
747
+ _address_cache.clear()
748
+ _address_cache[sha] = hit
749
+ return hit
750
+
751
+
752
+ class VoiceStage(Stage):
753
+ """view.voices (module documentation). Artifact "<task id>#view" (nnnotes.voices/1); facts rows, entries (rows
754
+ per status), gaps, unreferenced (streams of the decoded sheets no row reaches). No items."""
755
+ name = STAGE
756
+ after = (views.ADDRESSES_STAGE, EXPORT_STAGE, AUDIO_STAGE)
757
+
758
+ def __init__(self, rules: dict | None = None):
759
+ self.rules = load_rules() if rules is None else {"format": rules["format"], VIEW: rules[VIEW]}
760
+ check_rules(self.rules)
761
+ self.version = self.rules[VIEW]["version"]
762
+ self.rules_bytes = contract.encode(self.rules)
763
+ self.tables = tables_of(self.rules)
764
+ self._memo = None
765
+
766
+ def __getstate__(self):
767
+ return {"rules": self.rules}
768
+
769
+ def __setstate__(self, state):
770
+ self.__init__(state["rules"])
771
+
772
+ def subjects(self, env) -> list[str]:
773
+ if env.facts.get("master") is None:
774
+ return []
775
+ selected = env.facts.get("views")
776
+ if selected is not None and VIEW not in selected:
777
+ return []
778
+ return sorted(env.fact("catalogs"))
779
+
780
+ def _audio_tasks(self, env) -> list[str]:
781
+ pending = env.pending(AUDIO_STAGE)
782
+ if pending:
783
+ raise Pending(pending[0])
784
+ return env.tasks(AUDIO_STAGE)
785
+
786
+ def _export_tasks(self, env) -> list[str]:
787
+ pending = env.pending(EXPORT_STAGE)
788
+ if pending:
789
+ raise Pending(pending[0])
790
+ return env.tasks(EXPORT_STAGE)
791
+
792
+ def depends(self, subject: str, env) -> list[str]:
793
+ return ([contract.task_id(views.ADDRESSES_STAGE, subject)] + self._audio_tasks(env)
794
+ + self._plan(subject, env)[6])
795
+
796
+ def context(self, subject: str, env) -> dict:
797
+ sheets = self._plan(subject, env)[2]
798
+ return {"audio": {t: env.key(t) for t in sorted({s["task"] for s in sheets.values()})}}
799
+
800
+ def _sheets(self, env) -> tuple[dict, dict]:
801
+ """({sheet: {"task", "acb"}}, {acb sha256: Input}) of the cri.audio results: the sheets their contents are
802
+ known by (cristages.AudioStage.index: the catalog keys and the cue sheet names of the ACB artifacts), and
803
+ the ACBs of the decoded ones (an unsupported sheet's ACB is not read)."""
804
+ tids = self._audio_tasks(env)
805
+ if not tids:
806
+ return {}, {}
807
+ from .cristages import AudioStage
808
+ index = AudioStage().index(env)
809
+ sheets, acbs = {}, {}
810
+ for tid in tids:
811
+ sha = contract.parse_task_id(tid, AUDIO_STAGE)[1]
812
+ e = index.get(sha)
813
+ if e is None:
814
+ continue
815
+ decoded = any(i.get("status") == "exported" for i in env.result(tid)["items"])
816
+ for n in sorted(e["names"]):
817
+ sheet = _sheet_name(n)
818
+ if sheet is not None and sheet not in sheets:
819
+ sheets[sheet] = {"task": tid, "acb": sha}
820
+ if decoded:
821
+ a = e["acb"]
822
+ acbs[sha] = Input(f"acb:{sha}", a.sha256, a.size, a.name, a.locators)
823
+ return dict(sorted(sheets.items())), acbs
824
+
825
+ def _plan(self, subject: str, env) -> tuple:
826
+ """(master inputs, the recorded part of the address table, {sheet: {task, acb}}, ACB inputs, {episode asset:
827
+ {part: {object, artifact, sha256}}}, episode content inputs, the unity.export tasks read) for a task: of the
828
+ decoded sheets, those a row names or the prefixes list (a music or effect sheet is not read); of the story
829
+ episodes, the parts the unity.export results hold. Kept for the planning state it was made in (context,
830
+ inputs and depends ask for it in turn)."""
831
+ link = contract.task_id(views.ADDRESSES_STAGE, subject)
832
+ tids = self._audio_tasks(env)
833
+ exports = self._export_tasks(env)
834
+ masters = self._masters(env)
835
+ sig = (subject, env.key(link), tuple((t, env.key(t)) for t in tids), tuple((t, env.key(t)) for t in exports),
836
+ tuple(i.sha256 for i in masters), id(env.store), id(env.facts.get("index")))
837
+ if self._memo is not None and self._memo[0] == sig:
838
+ return self._memo[1]
839
+ index, files = _address_parts(env, link)
840
+ tables = {i.role[len(views.MASTER_ROLE):]: views._cached_rows(i.sha256, Path(i.locators[0]["path"]).read_bytes)
841
+ for i in masters}
842
+ found, acbs = self._sheets(env)
843
+ rec = views.Recorder(index)
844
+ stub = build(self.rules, tables, rec, {s: {"status": "not-exported", "streams": {}, "cues": {}}
845
+ for s in found})
846
+ head = self.rules[VIEW]["key"].split("{", 1)[0]
847
+ wanted = {r["sound"]["sheet"] for r in stub["rows"] if r["sound"].get("sheet")}
848
+ wanted |= {k[len(head):] for p in self.rules[VIEW].get("prefixes", []) for k in rec.keys(p)
849
+ if k.startswith(head)}
850
+ sheets = {s: v for s, v in found.items() if s in wanted}
851
+ used = {v["acb"] for v in sheets.values()}
852
+ episodes, contents, read = self._episodes(env, rec, tables, files, set(exports))
853
+ plan = (masters, rec.subset(), sheets, [acbs[a] for a in sorted(acbs) if a in used], episodes, contents,
854
+ read)
855
+ self._memo = (sig, plan)
856
+ return plan
857
+
858
+ def _episodes(self, env, index, tables, files: dict, exports: set) -> tuple[dict, list[Input], list[str]]:
859
+ """The story parts the unity.export results hold: the object a part's key names (the first of its class,
860
+ STORY_OBJECTS) in the bundle of its serialized file (the address table's `files`), and the artifact with
861
+ its content. ({asset: {part: {object, artifact, sha256}}}, the content inputs, the tasks read.)"""
862
+ arts: dict[str, dict] = {}
863
+ out: dict = {}
864
+ contents: dict[str, Input] = {}
865
+ read = set()
866
+ for asset in story_assets(self.rules, tables):
867
+ for part, key in story_keys(self.rules, asset).items():
868
+ cls, role = STORY_OBJECTS[part]
869
+ pick = views.choose(index.objects(key) or [], None, cls)
870
+ if not pick:
871
+ continue
872
+ oid = pick[0].id
873
+ stable = files.get(contract.parse_object_id(oid)[0])
874
+ tid = contract.task_id(EXPORT_STAGE, stable) if stable is not None else None
875
+ if tid not in exports:
876
+ continue
877
+ if tid not in arts:
878
+ arts[tid] = {a["id"]: a for a in env.result(tid)["artifacts"]}
879
+ aid = contract.artifact_id(oid, role)
880
+ a = arts[tid].get(aid)
881
+ if a is None:
882
+ continue
883
+ c = a["content"]
884
+ out.setdefault(asset, {})[part] = {"object": oid, "artifact": aid, "sha256": c["sha256"]}
885
+ contents.setdefault(c["sha256"], Input(f"adv:{c['sha256']}", c["sha256"], c["size"], None,
886
+ ({"kind": "store"},)))
887
+ read.add(tid)
888
+ return dict(sorted(out.items())), [contents[k] for k in sorted(contents)], sorted(read)
889
+
890
+ def inputs(self, subject: str, env) -> list[Input]:
891
+ masters, subset, sheets, acbs, episodes, contents, _ = self._plan(subject, env)
892
+ store = env.store
893
+ return (masters + [views._stored(store, "addresses", contract.encode(subset)),
894
+ views._stored(store, "rules", self.rules_bytes),
895
+ views._stored(store, "sheets", contract.encode(sheets)),
896
+ views._stored(store, "episodes", contract.encode(episodes))] + acbs + contents)
897
+
898
+ def _masters(self, env) -> list[Input]:
899
+ mdir = Path(env.fact("master"))
900
+ out = []
901
+ for t in self.tables:
902
+ p = mdir / f"{t}.json"
903
+ if not p.is_file():
904
+ raise views.ViewError(f"view {VIEW}: master table {t} is not in the master data")
905
+ sha, size = env.store.identify(p)
906
+ out.append(Input(views.MASTER_ROLE + t, sha, size, p.name, ({"kind": "file", "path": str(p)},)))
907
+ return out
908
+
909
+ def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
910
+ size = sum(i.size for i in inputs)
911
+ big = max((i.size for i in inputs if i.role.startswith("acb:")), default=0)
912
+ story = [i.size for i in inputs if i.role.startswith("adv:")]
913
+ return Cost(0.2 + size / 300e6 + sum(story) / 80e6,
914
+ (96 << 20) + 4 * big + 16 * sum(i.size for i in inputs if i.role.startswith(views.MASTER_ROLE))
915
+ + 2 * sum(story) + 12 * max(story, default=0))
916
+
917
+ def run(self, task, store) -> Output:
918
+ rules = contract.loads(store.input_bytes(task.input("rules")))
919
+ check_rules(rules)
920
+ if rules[VIEW]["version"] != task.version:
921
+ raise views.ViewError(f"task {task.id}: its rules are not those of view {VIEW} version {task.version}")
922
+ masters = [i for i in task.inputs if i.role.startswith(views.MASTER_ROLE)]
923
+ tables = {i.role[len(views.MASTER_ROLE):]: views._cached_rows(i.sha256, functools.partial(store.input_bytes, i))
924
+ for i in masters}
925
+ index = views.AddressIndex.from_subset(contract.loads(store.input_bytes(task.input("addresses"))))
926
+ sheets = contract.loads(store.input_bytes(task.input("sheets")))
927
+ results = task.context.get("audio", {})
928
+ roles = {i.role for i in task.inputs}
929
+ cues: dict[str, dict] = {}
930
+ audio = {}
931
+ for sheet, s in sorted(sheets.items()):
932
+ key = results.get(s["task"])
933
+ res = store.result(key) if key is not None else None
934
+ if res is None:
935
+ raise views.ViewError(f"task {task.id}: no result of {s['task']}")
936
+ role = f"acb:{s['acb']}"
937
+ if role in roles and s["acb"] not in cues:
938
+ cues[s["acb"]] = acb.cue_streams(store.input_bytes(task.input(role)))
939
+ audio[sheet] = sheet_audio(res, cues.get(s["acb"]))
940
+ snapshot = {"tables": {i.role[len(views.MASTER_ROLE):]: i.sha256 for i in masters},
941
+ "sheets": {k: {"task": v["task"], "acb": v["acb"], "status": audio[k]["status"]}
942
+ for k, v in sorted(sheets.items())}}
943
+ story = {}
944
+ if "episodes" in roles:
945
+ snapshot["episodes"] = task.input("episodes").sha256
946
+ column = rules[VIEW]["sound"]["sheet"]
947
+ for asset, parts in sorted(contract.loads(store.input_bytes(task.input("episodes"))).items()):
948
+ got = {p: store.input_bytes(task.input(f"adv:{x['sha256']}")) for p, x in parts.items()}
949
+ story[asset] = storyvoices.episode(got.get("script"), got.get("text"), got.get("sound"),
950
+ got.get("sheets"), sheet_column=column)
951
+ doc = build(rules, tables, index, audio, snapshot, story)
952
+ content = store.add(contract.encode(doc), "json")
953
+ facts = {"rows": len(doc["rows"]), "entries": counts(doc), "gaps": gaps(doc),
954
+ "unreferenced": unreferenced_streams(doc)}
955
+ rec = contract.artifact(contract.artifact_id(task.id, views.VIEW_ROLE), content, contract.provenance(task),
956
+ {"kind": "view", "format": "json", "facts": facts})
957
+ return Output([rec], [])
958
+
959
+ def derived(self, task_id: str, result: dict, store, paths) -> list[tuple[str, bytes]]:
960
+ """The voices document with the layout path of every stream, at views/voices.json."""
961
+ aid = contract.artifact_id(task_id, views.VIEW_ROLE)
962
+ rec = next((a for a in result["artifacts"] if a["id"] == aid), None)
963
+ if rec is None:
964
+ raise ValueError(f"result of {task_id} has no artifact {aid}")
965
+ doc = contract.loads(store.read(rec["content"]["sha256"]))
966
+ subject = contract.parse_task_id(task_id, self.name)[1]
967
+ return [(views.view_path(VIEW, subject), contract.encode(with_paths(doc, paths)))]
968
+
969
+
970
+ # ---------------------------------------------------------------- queries
971
+ def _norm(s) -> str:
972
+ return unicodedata.normalize("NFKC", str(s or "")).casefold()
973
+
974
+
975
+ def _flat(s) -> str:
976
+ return re.sub(r"[\s_\-.]+", "", _norm(s))
977
+
978
+
979
+ def character_ids(doc: dict, spec: str) -> set:
980
+ """The ids of the document's characters that `spec` names: an id, or a part of a name in any language."""
981
+ if spec.strip().isdigit():
982
+ return {int(spec)}
983
+ want = _norm(spec).strip()
984
+ out = set()
985
+ for c in doc["characters"]:
986
+ texts = [c.get("token")] + [t for n in c["names"].values() for t in (n or {}).values()]
987
+ if any(want and want in _norm(t) for t in texts if t):
988
+ out.add(c["id"])
989
+ return out
990
+
991
+
992
+ def _category_match(row: dict, spec: str) -> bool:
993
+ want = _flat(spec)
994
+ src, cat = row["source"]["name"], row["category"].get("name")
995
+ return want in {_flat(src), _flat(cat), _flat(f"{src}.{cat}")}
996
+
997
+
998
+ def episode_ids(doc: dict, spec: str) -> set:
999
+ """The ids of the document's episodes that `spec` names: an id, or an episode asset."""
1000
+ spec = spec.strip()
1001
+ return {e["id"] for e in doc.get("episodes", []) if str(e["id"]) == spec or e.get("asset") == spec}
1002
+
1003
+
1004
+ def select(doc: dict, *, characters=None, category=None, source=None, status=None, text=None,
1005
+ language=None, episodes=None) -> list[dict]:
1006
+ """The rows of a document matching every given filter (characters: a set of ids, a row matches by its
1007
+ characters or, for Sound and Story rows, the inferred ones; text: a part of the row's text in `language`, every
1008
+ language when None, or of its cue name; episodes: a set of episode ids, the Story rows of those)."""
1009
+ out = []
1010
+ want = _norm(text) if text else None
1011
+ for r in doc["rows"]:
1012
+ if episodes is not None and (r["source"]["name"] != STORY_SOURCE or r["source"].get("row") not in episodes):
1013
+ continue
1014
+ if source and _flat(r["source"]["name"]) != _flat(source):
1015
+ continue
1016
+ if status and r["status"] != status:
1017
+ continue
1018
+ if category and not _category_match(r, category):
1019
+ continue
1020
+ if characters is not None:
1021
+ chars = set(r["characters"]) | set(r.get("inferred", {}).get("characters", []))
1022
+ if not chars & characters:
1023
+ continue
1024
+ if want is not None:
1025
+ texts = (r.get("text") or {}).get("texts") or {}
1026
+ pool = [texts.get(language)] if language else list(texts.values())
1027
+ pool += [r["sound"].get("cue")]
1028
+ if not any(t and want in _norm(t) for t in pool):
1029
+ continue
1030
+ out.append(r)
1031
+ return out
1032
+
1033
+
1034
+ def rows_for(doc: dict, ident: str) -> list[dict]:
1035
+ """The rows of a row id, or of a MasterSound id (every row naming the sound)."""
1036
+ by_id = [r for r in doc["rows"] if r["id"] == ident]
1037
+ if by_id:
1038
+ return by_id
1039
+ if ident.strip().isdigit():
1040
+ n = int(ident)
1041
+ return [r for r in doc["rows"] if r["sound"].get("id") == n]
1042
+ return []
1043
+
1044
+
1045
+ def summary(doc: dict) -> dict:
1046
+ """Rows per status, per source and category, per character; gaps both ways."""
1047
+ by_cat: dict = {}
1048
+ by_char: dict = {}
1049
+ for r in doc["rows"]:
1050
+ k = f"{r['source']['name']}.{r['category'].get('name')}"
1051
+ c = by_cat.setdefault(k, {"rows": 0, "ok": 0})
1052
+ c["rows"] += 1
1053
+ c["ok"] += r["status"] == "ok"
1054
+ for ch in r["characters"] or r.get("inferred", {}).get("characters", []) or [None]:
1055
+ x = by_char.setdefault(str(ch), {"rows": 0, "ok": 0})
1056
+ x["rows"] += 1
1057
+ x["ok"] += r["status"] == "ok"
1058
+ rev = doc["coverage"]["reverse"]
1059
+ fam: dict = {}
1060
+ for sheet, left in rev["unreferencedStreams"].items():
1061
+ f = re.sub(r"\d+", "N", sheet)
1062
+ fam[f] = fam.get(f, 0) + len(left)
1063
+ keys: dict = {}
1064
+ for k in rev["unreferencedKeys"]:
1065
+ f = re.sub(r"\d+", "N", k)
1066
+ keys[f] = keys.get(f, 0) + 1
1067
+ out = {"rows": len(doc["rows"]), "statuses": counts(doc), "gaps": gaps(doc),
1068
+ "categories": dict(sorted(by_cat.items())), "characters": dict(sorted(by_char.items())),
1069
+ "forward": {s: [r["id"] for r in doc["rows"] if r["status"] == s] for s in GAP_STATUSES},
1070
+ "reverse": {"keys": rev["keys"], "streams": rev["streams"],
1071
+ "unreferencedKeys": dict(sorted(keys.items())),
1072
+ "unreferencedStreams": dict(sorted(fam.items()))}}
1073
+ if "episodes" in doc:
1074
+ out["story"] = _story_summary(doc)
1075
+ return out
1076
+
1077
+
1078
+ def _story_summary(doc: dict) -> dict:
1079
+ """Episodes per status and kind; per speaker name its rows and inferred characters; the story voices whose cue
1080
+ (sheet and cue name) another source's row also names, per source."""
1081
+ eps = doc["episodes"]
1082
+ statuses: dict = {}
1083
+ kinds: dict = {}
1084
+ for e in eps:
1085
+ statuses[e["status"]] = statuses.get(e["status"], 0) + 1
1086
+ k = kinds.setdefault(str(e.get("kind")), {"episodes": 0, "voices": 0})
1087
+ k["episodes"] += 1
1088
+ k["voices"] += e.get("voices", 0)
1089
+ others: dict = {}
1090
+ for r in doc["rows"]:
1091
+ if r["source"]["name"] != STORY_SOURCE and r["sound"].get("sheet") and r["sound"].get("cue"):
1092
+ others.setdefault((r["sound"]["sheet"], r["sound"]["cue"]), set()).add(r["source"]["name"])
1093
+ speakers: dict = {}
1094
+ shared = {"rows": 0, "cues": set(), "sources": {}}
1095
+ for r in doc["rows"]:
1096
+ if r["source"]["name"] != STORY_SOURCE:
1097
+ continue
1098
+ name = (r.get("speaker") or {}).get("name") or ""
1099
+ s = speakers.setdefault(name, {"rows": 0, "characters": []})
1100
+ s["rows"] += 1
1101
+ for c in r.get("inferred", {}).get("characters", []):
1102
+ if c not in s["characters"]:
1103
+ s["characters"].append(c)
1104
+ cue = (r["sound"].get("sheet"), r["sound"].get("cue"))
1105
+ if cue in others:
1106
+ shared["rows"] += 1
1107
+ shared["cues"].add(cue)
1108
+ for src in others[cue]:
1109
+ shared["sources"][src] = shared["sources"].get(src, 0) + 1
1110
+ shared["cues"] = len(shared["cues"])
1111
+ shared["sources"] = dict(sorted(shared["sources"].items()))
1112
+ return {"episodes": len(eps), "statuses": dict(sorted(statuses.items())), "kinds": dict(sorted(kinds.items())),
1113
+ "gaps": doc["coverage"]["sources"].get(STORY_SOURCE, {}).get("episodes", {}).get("gaps", []),
1114
+ "speakers": dict(sorted(speakers.items())), "sharedCues": shared}
1115
+
1116
+
1117
+ # ---------------------------------------------------------------- command line
1118
+ def _out(text: str) -> None:
1119
+ sys.stdout.buffer.write(text.encode("utf-8"))
1120
+
1121
+
1122
+ def _print_json(obj) -> None:
1123
+ from .jsonio import dumps
1124
+ _out(dumps(obj, ensure_ascii=False, indent=1) + "\n")
1125
+
1126
+
1127
+ def _label(doc: dict, ids, language: str) -> str:
1128
+ names = {c["id"]: c for c in doc["characters"]}
1129
+ out = []
1130
+ for i in ids:
1131
+ c = names.get(i)
1132
+ n = ((c or {}).get("names", {}).get("name") or {}).get(language) if c else None
1133
+ out.append(n or (c or {}).get("token") or str(i))
1134
+ return ",".join(out) or "-"
1135
+
1136
+
1137
+ def _line(doc: dict, r: dict, language: str) -> str:
1138
+ chars = r["characters"] or r.get("inferred", {}).get("characters", [])
1139
+ text = ((r.get("text") or {}).get("texts") or {}).get(language) or ""
1140
+ cat = r["category"].get("name") or r.get("inferred", {}).get("situation") or "-"
1141
+ where = f"{r['sound'].get('sheet')}/{r['sound'].get('cue')}"
1142
+ who = _label(doc, chars, language)
1143
+ if "speaker" in r:
1144
+ names = [(t.get("texts") or {}).get(language) for t in r["speaker"]["texts"]]
1145
+ who = ",".join(n for n in names if n) or r["speaker"]["name"] or who
1146
+ return "\t".join([r["id"], who, f"{r['source']['name']}.{cat}", where, r["status"],
1147
+ text.replace("\n", " ")]) + "\n"
1148
+
1149
+
1150
+ def _story_ids(tables, rules: dict, specs) -> dict:
1151
+ """{episode asset: adv id} of the story table's rows that `specs` name (an id or an asset each)."""
1152
+ st = rules[VIEW]["story"]
1153
+ want = {str(s).strip() for s in specs}
1154
+ out = {}
1155
+ for r in views._Lookup(tables).rows(st["table"]):
1156
+ a = r.get(st["asset"])
1157
+ if isinstance(a, str) and a and (str(r.get("_id")) in want or a in want):
1158
+ out[a] = r.get("_id")
1159
+ return out
1160
+
1161
+
1162
+ def _document(args, cfg, common, episodes=()) -> tuple[dict, Path | None]:
1163
+ """The document of --from, else the index of the configured master data and catalog, with the story episodes
1164
+ `episodes` (ids or assets) read from the catalog."""
1165
+ if args.source_dir:
1166
+ p = Path(args.source_dir)
1167
+ f = p if p.is_file() else p / views.view_path(VIEW, views.MAIN_SUBJECT)
1168
+ if not f.is_file():
1169
+ args.usage(f"--from {p}: no {views.view_path(VIEW, views.MAIN_SUBJECT)} (export --views voices "
1170
+ f"writes it)")
1171
+ doc = contract.loads(f.read_bytes())
1172
+ if doc.get("schema") != SCHEMA:
1173
+ args.usage(f"{f}: not a voices document")
1174
+ return doc, (f.parent.parent if not p.is_file() else None)
1175
+ rules = load_rules()
1176
+ tables = views.MasterTables(common.master_dir(cfg))
1177
+ cat = common.open_catalog(cfg, bundles=False)
1178
+ prefixes = [SHEET_PREFIX] + ([rules[VIEW]["story"]["key"].split("{", 1)[0]] if "story" in rules[VIEW] else [])
1179
+ index = views.AddressIndex({k: [] for p in prefixes for k in cat.keys(p)})
1180
+ story = {}
1181
+ assets = _story_ids(tables, rules, episodes) if episodes and "story" in rules[VIEW] else {}
1182
+ if assets:
1183
+ full = common.open_catalog(cfg)
1184
+ column = rules[VIEW]["sound"]["sheet"]
1185
+ for asset in sorted(assets):
1186
+ got = storyvoices.load_episode(full, story_keys(rules, asset))
1187
+ story[asset] = storyvoices.episode(got.get("script"), got.get("text"), got.get("sound"),
1188
+ got.get("sheets"), sheet_column=column)
1189
+ return build(rules, tables, index, story=story), None
1190
+
1191
+
1192
+ def _language(args, cfg) -> str:
1193
+ lang = getattr(args, "language", None) or cfg.get("catalog", "language") or "ja"
1194
+ if lang not in LANGUAGES:
1195
+ args.usage(f"--language {lang}: one of {', '.join(LANGUAGES)}")
1196
+ return lang
1197
+
1198
+
1199
+ def _filters(args, doc: dict) -> dict:
1200
+ chars = None
1201
+ if getattr(args, "character", None):
1202
+ chars = character_ids(doc, args.character)
1203
+ if not chars:
1204
+ args.usage(f"--character {args.character}: no character of the master data has this id or name")
1205
+ eps = None
1206
+ if getattr(args, "episode", None):
1207
+ eps = episode_ids(doc, args.episode)
1208
+ if not eps:
1209
+ args.usage(f"--episode {args.episode}: no episode of the master data has this id or asset")
1210
+ return {"characters": chars, "category": getattr(args, "category", None), "source": getattr(args, "source", None),
1211
+ "status": getattr(args, "status", None), "episodes": eps}
1212
+
1213
+
1214
+ def _emit(args, doc: dict, rows: list[dict], language: str) -> None:
1215
+ rows = rows[: args.limit] if args.limit else rows
1216
+ if args.json:
1217
+ _print_json(rows)
1218
+ return
1219
+ for r in rows:
1220
+ _out(_line(doc, r, language))
1221
+
1222
+
1223
+ def _episode_args(args) -> tuple:
1224
+ return (args.episode,) if getattr(args, "episode", None) else ()
1225
+
1226
+
1227
+ def cmd_list(args, cfg, common) -> None:
1228
+ doc, _ = _document(args, cfg, common, _episode_args(args))
1229
+ lang = _language(args, cfg)
1230
+ _emit(args, doc, select(doc, **_filters(args, doc)), lang)
1231
+
1232
+
1233
+ def cmd_search(args, cfg, common) -> None:
1234
+ doc, _ = _document(args, cfg, common, _episode_args(args))
1235
+ lang = _language(args, cfg)
1236
+ only = args.language if args.language else None
1237
+ _emit(args, doc, select(doc, **_filters(args, doc), text=args.text, language=only), lang)
1238
+
1239
+
1240
+ def cmd_summary(args, cfg, common) -> None:
1241
+ doc, _ = _document(args, cfg, common)
1242
+ s = summary(doc)
1243
+ if args.json:
1244
+ _print_json(s)
1245
+ return
1246
+ _out(f"rows {s['rows']} gaps {s['gaps']} " + " ".join(f"{k} {v}" for k, v in s["statuses"].items()) + "\n")
1247
+ for k, v in s["categories"].items():
1248
+ _out(f" {k}\t{v['rows']}\t{v['ok']} ok\n")
1249
+ rev = s["reverse"]
1250
+ _out(f"reverse: {rev['keys']} keys, {sum(rev['unreferencedKeys'].values())} named by no row; {rev['streams']} "
1251
+ f"decoded streams, {sum(rev['unreferencedStreams'].values())} reached by no row\n")
1252
+ if "story" in s:
1253
+ st = s["story"]
1254
+ _out(f"story: {st['episodes']} episodes (" + ", ".join(f"{k} {v}" for k, v in st["statuses"].items())
1255
+ + f"); {st['sharedCues']['rows']} voices share a cue with another source\n")
1256
+ for k, v in st["kinds"].items():
1257
+ _out(f" {k}\t{v['episodes']} episodes\t{v['voices']} voices\n")
1258
+
1259
+
1260
+ def _decode_one(cfg, common, sheet: str, cue: str, fmt: str, level: int, dst: Path) -> list[Path]:
1261
+ """The files of a cue's streams, decoded from its sheet alone (cri.decode, as the export decodes a sheet)."""
1262
+ from . import cri
1263
+ cfg.require_path("paths", "apk")
1264
+ cat = common.open_catalog(cfg)
1265
+ files, _ = cri.acb_data(cat, sheet)
1266
+ c = acb.cue_streams(files["acb"]).get(cue)
1267
+ if c is None:
1268
+ raise LookupError(f"sheet {sheet} has no cue {cue}")
1269
+ with tempfile.TemporaryDirectory(prefix="nnnotes-voice-") as tmp:
1270
+ cri.decode(cat, sheet, Path(tmp), fmt=fmt, flac_level=level)
1271
+ streams = {s["stream"]: s for s in json.loads((Path(tmp) / "streams.json").read_text(encoding="utf-8"))}
1272
+ out = []
1273
+ for w in c["streams"]:
1274
+ s = streams.get(w.get("stream"))
1275
+ if s is None:
1276
+ continue
1277
+ out.append(dst / s["file"])
1278
+ shutil.copyfile(Path(tmp) / s["file"], out[-1])
1279
+ return out
1280
+
1281
+
1282
+ def cmd_get(args, cfg, common) -> None:
1283
+ head = f"{load_rules()[VIEW].get('story', {}).get('table')}:"
1284
+ story = (args.id[len(head):].split(":", 1)[0],) if args.id.startswith(head) else ()
1285
+ doc, base = _document(args, cfg, common, story)
1286
+ rows = rows_for(doc, args.id)
1287
+ if not rows:
1288
+ args.usage(f"{args.id}: no voice row or sound id of that number")
1289
+ r = next((x for x in rows if x["status"] == "ok"), rows[0])
1290
+ sheet, cue = r["sound"].get("sheet"), r["sound"].get("cue")
1291
+ dst = Path(args.out)
1292
+ dst.mkdir(parents=True, exist_ok=True)
1293
+ written = []
1294
+ streams = (r.get("audio") or {}).get("streams", [])
1295
+ if base is not None and args.format == "flac" and streams and all(s.get("path") for s in streams):
1296
+ for s in streams:
1297
+ written.append(dst / s["file"])
1298
+ shutil.copyfile(base / s["path"], written[-1])
1299
+ elif sheet is None or cue is None or r["status"] in ("no-value", "missing-key", "missing-cue", "unsupported"):
1300
+ print(f"nnnotes: {r['id']}: {r['status']}" + (f" ({r['detail']})" if r.get("detail") else ""),
1301
+ file=sys.stderr)
1302
+ sys.exit(1)
1303
+ else:
1304
+ from .cristages import FLAC_LEVEL
1305
+ try:
1306
+ written = _decode_one(cfg, common, sheet, cue, args.format, FLAC_LEVEL, dst)
1307
+ except LookupError as e:
1308
+ print(f"nnnotes: {r['id']}: {e}", file=sys.stderr)
1309
+ sys.exit(1)
1310
+ from .jsonio import dumps
1311
+ (dst / "voice.json").write_text(dumps({"rows": rows, "files": [p.name for p in written]}, ensure_ascii=False,
1312
+ indent=1) + "\n", encoding="utf-8", newline="\n")
1313
+ for p in written:
1314
+ _out(f"{p}\n")
1315
+ if not written:
1316
+ sys.exit(1)
1317
+
1318
+
1319
+ def register(sub, common) -> None:
1320
+ """The `voices` command (common: open_catalog(cfg, bundles=...), master_dir(cfg))."""
1321
+ c = sub.add_parser("voices", help="character voices of the master data and the story episodes: list, search, "
1322
+ "summary, get one")
1323
+ vs = c.add_subparsers(dest="voices_cmd", required=True, metavar="<voices command>")
1324
+
1325
+ def base(p, text=True):
1326
+ p.add_argument("--from", dest="source_dir", metavar="OUT",
1327
+ help="an export directory with views/voices.json (export --views voices); else the index is "
1328
+ "built from the configured master data and catalog (no files)")
1329
+ if text:
1330
+ p.add_argument("--character", help="a MasterCharacter id or a part of a name in any language")
1331
+ p.add_argument("--category", help="a category (CharacterRankUp or character-rank-up), Source.Category "
1332
+ "or a source")
1333
+ p.add_argument("--source", help="a source (Talk, CharacterVoice, ..., HomeSpot, Title, Sound, Story)")
1334
+ p.add_argument("--episode", metavar="ADV", help="the Story voices of one episode: a MasterAdv id or an "
1335
+ "episode asset (without --from: read from the catalog)")
1336
+ p.add_argument("--status", choices=STATUSES, help="only the rows with this status")
1337
+ p.add_argument("--limit", type=int, default=0, help="at most N rows (0: all)")
1338
+ p.add_argument("--json", action="store_true", help="the rows as JSON")
1339
+
1340
+ def done(p, fn):
1341
+ p.set_defaults(func=lambda args, cfg: fn(args, cfg, common), usage=p.error)
1342
+
1343
+ p = vs.add_parser("list", help="the voices matching the filters")
1344
+ base(p)
1345
+ p.add_argument("--language", choices=LANGUAGES, help="the language of the text column ([catalog] language)")
1346
+ done(p, cmd_list)
1347
+ p = vs.add_parser("search", help="the voices whose text (or cue name) contains TEXT")
1348
+ p.add_argument("text", help="text to look for")
1349
+ base(p)
1350
+ p.add_argument("--language", choices=LANGUAGES, help="search this language only (default: all five)")
1351
+ done(p, cmd_search)
1352
+ p = vs.add_parser("summary", help="rows per status, category and character; gaps both ways")
1353
+ base(p, text=False)
1354
+ p.add_argument("--json", action="store_true", help="the summary as JSON")
1355
+ done(p, cmd_summary)
1356
+ p = vs.add_parser("get", help="the files of one voice: a row id (MasterTalk:561, MasterAdv:<id>:<command>) or a "
1357
+ "sound id")
1358
+ p.add_argument("id", help="row id or sound id")
1359
+ p.add_argument("-o", "--out", required=True, help="output directory")
1360
+ base(p, text=False)
1361
+ p.add_argument("--format", choices=("flac", "ogg", "wav"), default="flac",
1362
+ help="flac (default: the exported files when --from has them), ogg or wav (decodes the sheet)")
1363
+ done(p, cmd_get)