A precise request for the audio system's data, plus a validator to check it against
Asks for what is needed to write a correct mapping, in a form that can be verified rather than read. Two documents: docs/audio-system-data-request.md - what to send, in what format, and why that format. docs/audio-system-bundle-validator.py - ships *with* the request rather than after it, so the sender can check their own work before it travels. Standard library only: whoever runs it runs it on the audio system, and adding a dependency to someone's working machine to validate a file is not a reasonable thing to ask. That means the schema checking is the subset possible without a library - a smoke alarm, not an inspector - and the file says so rather than implying more. The reasoning behind the format is the substance. Prose cannot be validated: every mismatch is something a human has to notice, and the ones that get missed are the interesting ones - derived chapter order, two fields that mean slightly different things, a path relative to something unspecified. A validator that names the record and the field turns a round trip into a checklist, and reports every problem rather than stopping at the first, because a validator that stops at the first failure makes a three-round-trip conversation. Also asks for the things that are cheapest to supply and most expensive to guess: the vocabulary table (guessing terminology is how a mapping looks correct and reads the wrong field), which fields are derived (a derived chapter order treated as authoritative will disagree the first time somebody reorders a chapter by hand), and what the TTS engine actually is - fork or patch, what changed, whether it loads a model and how big. That last one decides whether a 48 GB CPU-only node can run it at all, so the speak work function cannot be written until it is answered. Explicitly not asked for: media files, source code, screenshots, or anything the sender would have to reconstruct. "We do not record this" is a complete answer and far better than an estimate presented as fact. The validator was itself tested against a deliberately broken bundle, which is the only way to know a check can fail. It caught a dangling reference, a unitless duration, two unanchored paths and a planted credential, and passed a correct bundle. Two flaws in it were found that way and fixed: it reported the same path problem twice, and every credential twice, because a tool whose only job is to be trusted about specifics loses credibility the first time it says the same thing twice. 704 passed, 4 skipped - no product code touched.
This commit is contained in:
287
docs/audio-system-bundle-validator.py
Normal file
287
docs/audio-system-bundle-validator.py
Normal file
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate an audio-system bundle against the request in this repository.
|
||||
|
||||
This ships *with* the request, not after it. The point of asking for machine-readable
|
||||
metadata rather than prose is that the sender can check their own work before it travels;
|
||||
a round trip that comes back with forty precise errors beats one that comes back with
|
||||
"mostly fine, I think".
|
||||
|
||||
Every problem is reported, not just the first. A validator that stops at the first failure
|
||||
turns one exchange into three, because each run reveals one more problem and the sender has
|
||||
to guess that there are others.
|
||||
|
||||
python3 validate.py <bundle-directory>
|
||||
|
||||
Exits 0 when the bundle is usable, 1 when it is not, 2 when the bundle cannot be read at
|
||||
all. Prints one line per problem with the record and field it applies to.
|
||||
|
||||
Only the standard library is used, deliberately: whoever runs this is running it on the
|
||||
audio system, and adding a dependency to someone's working machine to check a file is not a
|
||||
reasonable thing to ask. That means the JSON Schema checks here are the subset that can be
|
||||
done without a library - enough to catch the mistakes that actually happen, and no more. A
|
||||
full schema validator is the reader's job; this is a smoke alarm, not an inspector.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED_FILES = (
|
||||
"README.md",
|
||||
"bundle.json",
|
||||
"VOCABULARY.md",
|
||||
"manifest.jsonl",
|
||||
"validate.py",
|
||||
)
|
||||
|
||||
def fail(problems: list[str], message: str) -> None:
|
||||
problems.append(message)
|
||||
|
||||
|
||||
def check_structure(root: Path, problems: list[str]) -> bool:
|
||||
"""The bundle's shape. Missing pieces are reported and counted, not fatal per file."""
|
||||
|
||||
missing = [name for name in REQUIRED_FILES if not (root / name).exists()]
|
||||
if missing:
|
||||
fail(problems, f"missing required file(s): {', '.join(missing)}")
|
||||
for directory in ("schemas", "examples"):
|
||||
if not (root / directory).is_dir():
|
||||
fail(problems, f"missing directory: {directory}/")
|
||||
return not missing
|
||||
|
||||
|
||||
def load_manifest(root: Path, problems: list[str]) -> list[dict]:
|
||||
"""Parse manifest.jsonl, reporting the line number of anything unreadable.
|
||||
|
||||
Line numbers matter more than they look: the sender's next action is to open the file at
|
||||
the line the validator names, so an error saying "record 4 is broken" has cost them a
|
||||
search.
|
||||
"""
|
||||
|
||||
path = root / "manifest.jsonl"
|
||||
if not path.exists():
|
||||
return []
|
||||
records: list[dict] = []
|
||||
for number, line in enumerate(path.read_text().splitlines(), start=1):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
fail(problems, f"manifest.jsonl:{number}: not valid JSON ({error.msg})")
|
||||
continue
|
||||
if not isinstance(record, dict):
|
||||
fail(problems, f"manifest.jsonl:{number}: a record, not a {type(record).__name__}")
|
||||
continue
|
||||
records.append(record)
|
||||
if not records:
|
||||
fail(problems, "manifest.jsonl contains no records")
|
||||
return records
|
||||
|
||||
|
||||
def check_required_fields(root: Path, records: list[dict], problems: list[str]) -> None:
|
||||
"""Whatever the bundle index declares required, each record must actually have.
|
||||
|
||||
Driven by bundle.json rather than a hardcoded list, because the sender knows their own
|
||||
records and I do not. A missing key that the sender said was required is the error this
|
||||
catches, and it is otherwise invisible until something downstream reads it.
|
||||
"""
|
||||
|
||||
index_path = root / "bundle.json"
|
||||
if not index_path.exists():
|
||||
return
|
||||
try:
|
||||
index = json.loads(index_path.read_text())
|
||||
except json.JSONDecodeError as error:
|
||||
fail(problems, f"bundle.json: not valid JSON ({error.msg})")
|
||||
return
|
||||
|
||||
required = index.get("required_fields") or {}
|
||||
if not isinstance(required, dict):
|
||||
fail(problems, "bundle.json: required_fields must be an object of type -> [fields]")
|
||||
return
|
||||
|
||||
for number, record in enumerate(records, start=1):
|
||||
kind = record.get("kind") or record.get("record_type") or record.get("type")
|
||||
for field in required.get(kind, []) or []:
|
||||
if field not in record:
|
||||
fail(problems, f"manifest.jsonl:{number}: ({kind}) missing required field '{field}'")
|
||||
|
||||
|
||||
def check_identifiers_resolve(records: list[dict], problems: list[str]) -> None:
|
||||
"""Every id that points at another record must point at one that exists.
|
||||
|
||||
A dangling reference is the failure mode that makes a mapping look complete and be
|
||||
wrong: the record is present, the link is plausible, and nothing checks it.
|
||||
"""
|
||||
|
||||
known: set[str] = set()
|
||||
for record in records:
|
||||
for key in ("id", "uuid"):
|
||||
if isinstance(record.get(key), str):
|
||||
known.add(record[key])
|
||||
|
||||
reference_fields = (
|
||||
"chapter_id", "book_id", "render_id", "parent_id", "source_id", "input_id",
|
||||
"voice_id", "takes", "inputs", "outputs", "chapters", "renders",
|
||||
)
|
||||
for number, record in enumerate(records, start=1):
|
||||
for field in reference_fields:
|
||||
if field not in record:
|
||||
continue
|
||||
value = record[field]
|
||||
candidates = value if isinstance(value, list) else [value]
|
||||
for one in candidates:
|
||||
if isinstance(one, dict):
|
||||
one = one.get("id")
|
||||
if isinstance(one, str) and one not in known:
|
||||
fail(
|
||||
problems,
|
||||
f"manifest.jsonl:{number}: '{field}' points at {one!r}, which is "
|
||||
"not in the manifest",
|
||||
)
|
||||
|
||||
|
||||
#: Field names that name a location, which is useless without something to resolve it
|
||||
#: against. A relative path with no declared base cannot be read at all, and an absolute
|
||||
#: one leaks the machine layout into a document meant to travel.
|
||||
PATH_FIELDS = frozenset({"path", "file", "filepath", "file_path", "location", "source_path"})
|
||||
|
||||
#: Fields that state a duration. Not flagged on their own - the request asks for units in
|
||||
#: the name, and a sender who wrote `duration_ms` has done it. This exists to catch the
|
||||
#: field that carries a time and says so nowhere, which is the one that is a factor of a
|
||||
#: thousand wrong and still looks plausible.
|
||||
DURATION_FIELDS = frozenset({"duration", "length", "elapsed", "runtime", "time"})
|
||||
|
||||
|
||||
def check_conventions(records: list[dict], problems: list[str]) -> None:
|
||||
"""Conventions the request asks for, checked mechanically.
|
||||
|
||||
One diagnostic per record per problem. The first version of this iterated a pattern
|
||||
table inside the field loop and reported the same path twice, which is worth avoiding
|
||||
for its own sake: a tool whose job is precise errors loses credibility the first time
|
||||
it says the same thing twice.
|
||||
"""
|
||||
|
||||
for number, record in enumerate(records, start=1):
|
||||
for field in record:
|
||||
lowered = field.lower()
|
||||
if lowered in PATH_FIELDS and not (
|
||||
"relative_to" in record or "path_base" in record or "path_is_absolute" in record
|
||||
):
|
||||
fail(
|
||||
problems,
|
||||
f"manifest.jsonl:{number}: '{field}' looks like a path and the record "
|
||||
"does not say what it is relative to (add 'relative_to' or "
|
||||
"'path_is_absolute')",
|
||||
)
|
||||
if lowered in DURATION_FIELDS:
|
||||
fail(
|
||||
problems,
|
||||
f"manifest.jsonl:{number}: '{field}' states a duration with no unit. "
|
||||
"Rename it to say which - duration_ms, duration_s - rather than leaving "
|
||||
"the reader to assume.",
|
||||
)
|
||||
|
||||
|
||||
def check_credentials(records: list[dict], root: Path, problems: list[str]) -> None:
|
||||
"""Refuse anything that looks like a secret.
|
||||
|
||||
The bundle travels between machines and gets committed to repositories. A key or token
|
||||
in it would be a real incident, and this is cheap to check - a substring match catches
|
||||
the shapes people actually use.
|
||||
|
||||
`manifest.jsonl` is scanned per record, with the line number, and then excluded from the
|
||||
file sweep. Scanning it twice reported every finding twice, which is noise in a tool
|
||||
whose only job is to be trusted about specifics.
|
||||
"""
|
||||
|
||||
suspicious = re.compile(
|
||||
r"(sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{12,}"
|
||||
r"|-----BEGIN[A-Z ]*PRIVATE KEY-----|eyJ[A-Za-z0-9_-]{20,}\.)"
|
||||
)
|
||||
for number, record in enumerate(records, start=1):
|
||||
if suspicious.search(json.dumps(record)):
|
||||
fail(
|
||||
problems,
|
||||
f"manifest.jsonl:{number}: looks like it contains a credential. Remove it "
|
||||
"before sending; this bundle is meant to be committable.",
|
||||
)
|
||||
for path in sorted(root.rglob("*")):
|
||||
if not path.is_file() or path.name == "manifest.jsonl":
|
||||
continue
|
||||
if path.suffix not in {".json", ".jsonl", ".md", ".env", ".yaml", ".yml"}:
|
||||
continue
|
||||
try:
|
||||
if suspicious.search(path.read_text(errors="ignore")):
|
||||
fail(
|
||||
problems,
|
||||
f"{path.relative_to(root)}: looks like it contains a credential",
|
||||
)
|
||||
except OSError: # pragma: no cover - unreadable file is not a credential problem
|
||||
pass
|
||||
|
||||
|
||||
def check_an_example_is_complete(root: Path, problems: list[str]) -> None:
|
||||
"""At least one worked example, with something in it.
|
||||
|
||||
The example is the part that actually teaches me the shape. A bundle with schemas and a
|
||||
manifest but no traced book leaves me inferring the relationships, which is the guessing
|
||||
this whole request exists to avoid.
|
||||
"""
|
||||
|
||||
examples = root / "examples"
|
||||
if not examples.is_dir():
|
||||
return
|
||||
books = [one for one in examples.iterdir() if one.is_dir()]
|
||||
if not books:
|
||||
fail(problems, "examples/ is empty: one real book traced end to end is the point")
|
||||
return
|
||||
for book in books:
|
||||
if not (book / "manifest.json").exists() or not (book / "lineage.json").exists():
|
||||
fail(
|
||||
problems,
|
||||
f"examples/{book.name}/: needs both manifest.json and lineage.json so the "
|
||||
"book can be traced from source to output",
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print(__doc__)
|
||||
return 2
|
||||
root = Path(argv[1])
|
||||
if not root.is_dir():
|
||||
print(f"Not a directory: {root}")
|
||||
return 2
|
||||
|
||||
problems: list[str] = []
|
||||
structure_ok = check_structure(root, problems)
|
||||
records = load_manifest(root, problems)
|
||||
check_required_fields(root, records, problems)
|
||||
check_identifiers_resolve(records, problems)
|
||||
check_conventions(records, problems)
|
||||
check_credentials(records, root, problems)
|
||||
check_an_example_is_complete(root, problems)
|
||||
|
||||
if problems:
|
||||
print(f"{len(problems)} problem(s) in {root}:\n")
|
||||
for problem in problems:
|
||||
print(f" - {problem}")
|
||||
print()
|
||||
if not structure_ok:
|
||||
print("The bundle is missing pieces listed above, so the checks below could not "
|
||||
"say much. Fix those first and run this again.")
|
||||
return 1
|
||||
|
||||
count = len(records)
|
||||
print(f"{root}: {count} record(s), structure complete, references resolve, "
|
||||
"no credentials found.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
213
docs/audio-system-data-request.md
Normal file
213
docs/audio-system-data-request.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# What I need from the audio system, and in what format
|
||||
|
||||
**To:** whoever works on the audio system (mini-m4)
|
||||
**From:** the UmbraTrans controller side
|
||||
**Why:** to write a correct mapping from the audio system's data into UmbraTrans' artifact
|
||||
model, without guessing and without touching the running system.
|
||||
|
||||
Nothing here asks you to change anything. The audio system stays independent and keeps
|
||||
working exactly as it does. This is a description of what exists, plus a definition of what
|
||||
the new system will expect, so both sides are writing against the same thing.
|
||||
|
||||
---
|
||||
|
||||
## The one thing that matters most
|
||||
|
||||
**Prose cannot be validated. JSON can.**
|
||||
|
||||
If the format arrives as a document describing fields, every mismatch is something a human
|
||||
has to notice, and the ones that get missed are the interesting ones — derived chapter
|
||||
order, two fields that mean slightly different things, a path that is relative to something
|
||||
unspecified.
|
||||
|
||||
So everything below is machine-readable, and it ships with a validator. When it does not
|
||||
match, the validator says exactly which record and which field, and exits non-zero. That
|
||||
turns a round trip into a checklist rather than a correspondence.
|
||||
|
||||
---
|
||||
|
||||
## P0 — blocks the mapping. Nothing else can start without these.
|
||||
|
||||
### 1. One complete book, traced end to end
|
||||
|
||||
Not a synthetic example. **One real book**, with real identifiers, showing the whole path:
|
||||
|
||||
```
|
||||
manuscript chapter → render → assembled audiobook
|
||||
```
|
||||
|
||||
For that one book I need every record that exists at each stage, with the identifiers that
|
||||
link them. If I can trace one book completely, I can infer the pattern; if I only have
|
||||
aggregate shapes I will guess, and the guess will be wrong somewhere invisible.
|
||||
|
||||
### 2. The vocabulary
|
||||
|
||||
Your words for things, and mine. I suspect these do not line up, and where they don't is
|
||||
where the bugs will be.
|
||||
|
||||
| Concept | What I call it | What you call it |
|
||||
|---|---|---|
|
||||
| one rendered piece of audio | ? | ? |
|
||||
| a chapter's worth of audio | ? | ? |
|
||||
| the assembled book | ? | ? |
|
||||
| the narration voice | ? | ? |
|
||||
| a take that was rejected | ? | ? |
|
||||
| the thing that gets published | ? | ? |
|
||||
|
||||
A table with two columns and a note per row is enough. Guessing terminology is how a
|
||||
mapping looks correct and reads the wrong field.
|
||||
|
||||
### 3. The fields of each record type, with meanings
|
||||
|
||||
For every kind of record the audio system keeps: its fields, what each means, which are
|
||||
required, and **which are derived** from something else.
|
||||
|
||||
Derived fields matter disproportionately. A derived chapter order that I treat as
|
||||
authoritative will disagree with your ordering the first time a chapter is reordered by
|
||||
hand.
|
||||
|
||||
---
|
||||
|
||||
## P1 — needed for correctness
|
||||
|
||||
### 4. Lineage: what produces what
|
||||
|
||||
Which step produces which output, so I can express a derivation rather than just a list.
|
||||
An artifact that says "I came from these inputs" is far more useful than one that exists.
|
||||
|
||||
### 5. What the TTS engine actually is
|
||||
|
||||
Not the intention — the reality. I need to write the `speak` work function and I will not
|
||||
write it blind:
|
||||
|
||||
- Is Qwen3-TTS a **fork** or a **patch** on the upstream? A link or a diff summary.
|
||||
- **What was changed, and why.** In particular anything affecting the input contract: does
|
||||
it take the text the same way, does it need segment markers, phoneme tags, or a specific
|
||||
format?
|
||||
- **Does it load a model, and how big?** This decides whether a 48 GB CPU-only node can run
|
||||
it at all, which changes the whole plan.
|
||||
- What the output actually is: one file per chapter, or per paragraph, or a stream?
|
||||
|
||||
### 6. Formats on disk
|
||||
|
||||
What each produced file physically is: container, codec, sample rate, channel layout,
|
||||
bitrate. And the naming convention, plus **what the names mean** — is the `003` in
|
||||
`ch003_take2.wav` a chapter number, an index, or a sort position?
|
||||
|
||||
### 7. Scale
|
||||
|
||||
Roughly: how many books, how many chapters per book, total hours, and total bytes. This
|
||||
sizes everything downstream and tells me whether the catalogue is something a person
|
||||
maintains or something that has to be generated.
|
||||
|
||||
---
|
||||
|
||||
## P2 — useful, not blocking
|
||||
|
||||
### 8. What exists but is not part of the product
|
||||
|
||||
Caches, scratch renders, rejected takes, test output. I need to know they exist so the
|
||||
mapping does not sweep them into the catalogue — a catalogue of scratch files is a catalogue
|
||||
nobody can answer a question against.
|
||||
|
||||
### 9. Anything that breaks
|
||||
|
||||
Known-bad files, chapters that fail, edge cases you already work around. This is the
|
||||
cheapest information I will ever get and it saves the most time.
|
||||
|
||||
---
|
||||
|
||||
## The exact format
|
||||
|
||||
A single compressed archive, so it is one thing to move and one thing to version:
|
||||
|
||||
```
|
||||
audio-system-bundle.tar.gz
|
||||
├── README.md # what this is, how it was generated, how to regenerate it
|
||||
├── bundle.json # root index: what is inside, counts, generated-at, source version
|
||||
├── VOCABULARY.md # your terms <-> mine, per concept
|
||||
├── schemas/
|
||||
│ ├── manifest.schema.json
|
||||
│ ├── <record-type>.schema.json # one per record type in the audio system
|
||||
│ └── lineage.schema.json
|
||||
├── manifest.jsonl # every record, one JSON object per line
|
||||
├── examples/
|
||||
│ └── <one-real-book>/
|
||||
│ ├── manifest.json # every record for this book only
|
||||
│ ├── lineage.json # what produced what, for this book
|
||||
│ └── README.md # a short prose trace of this book, for a human
|
||||
└── validate.py # exits non-zero, names the record and field on any mismatch
|
||||
```
|
||||
|
||||
### Conventions I am asking for, to remove ambiguity
|
||||
|
||||
These are the things that go wrong when nobody says them out loud:
|
||||
|
||||
- **Units in the field name.** `duration_ms`, `byte_size`, `vram_total_mb`. Not `duration`
|
||||
and not `length`.
|
||||
- **All timestamps UTC**, ISO 8601 with an explicit `Z` or `+00:00`.
|
||||
- **Explicit versions** on anything that can change: engine version, bundle format version,
|
||||
audio system version.
|
||||
- **Relative paths are declared relative to what.** If a path is relative, say relative to
|
||||
the book directory, and give one worked example of an absolute one.
|
||||
- **Null means absent, not unknown.** If a distinction is needed, use two fields or an
|
||||
explicit `"unknown"` — a null that means two things will be read as whichever the reader
|
||||
assumed.
|
||||
- **No credentials of any kind** in the bundle. No keys, no tokens, no hostnames that would
|
||||
be sensitive, no personal data. If a record needs a path that contains a username, replace
|
||||
it with a placeholder and say so in the README.
|
||||
|
||||
### The validator
|
||||
|
||||
`validate.py` should check that:
|
||||
|
||||
1. Every record validates against its schema.
|
||||
2. Every identifier referenced by another record exists.
|
||||
3. One real book traces end to end with no dangling references.
|
||||
|
||||
And it should **print every problem, not stop at the first**, then exit non-zero. A
|
||||
validator that stops at the first error makes a three-round-trip conversation.
|
||||
|
||||
---
|
||||
|
||||
## What I do not need
|
||||
|
||||
To keep this small and quick to produce:
|
||||
|
||||
- **Media files.** Not for this step. A handful of small samples later, once I know what I
|
||||
am looking at — the metadata tells me what to ask for samples of.
|
||||
- Source code, unless a specific question cannot be answered otherwise. I would rather ask
|
||||
one pointed question than read a repository.
|
||||
- Screenshots.
|
||||
- Anything you would have to reconstruct. If it does not exist, say so — "we do not record
|
||||
this" is a complete and useful answer, and far better than an estimate presented as fact.
|
||||
|
||||
---
|
||||
|
||||
## Where to put it
|
||||
|
||||
Anything I can read. The most convenient is a copy on **238**, which I already have access
|
||||
to; `scp` it there and tell me the path.
|
||||
|
||||
If it is easier to hand it over directly, that works too.
|
||||
|
||||
**Size:** the metadata bundle should be well under a megabyte. If what you have is much
|
||||
larger, send P0 first — the worked example, the vocabulary and the field definitions are
|
||||
enough for me to start.
|
||||
|
||||
---
|
||||
|
||||
## What happens next, so you know this is not a one-way request
|
||||
|
||||
Once I have it I will write back:
|
||||
|
||||
1. **A mapping document** — every source field to every UmbraTrans field, with anything
|
||||
that has no equivalent marked as such rather than quietly dropped.
|
||||
2. **A manifest format** UmbraTrans will accept, as a JSON Schema plus a validator, so the
|
||||
audio system can emit it directly whenever you want.
|
||||
3. **A first ingest**, once the format agrees, which copies metadata and references into
|
||||
UmbraTrans. **The audio system keeps its data and keeps running.** Nothing is moved out
|
||||
from under it and nothing it depends on is touched.
|
||||
|
||||
If the mapping turns out to need something the bundle does not have, I will come back with
|
||||
a specific question rather than an open-ended request.
|
||||
Reference in New Issue
Block a user