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:
umbra audio
2026-10-05 19:35:21 -07:00
parent 9432366183
commit 70c23fde1b
2 changed files with 500 additions and 0 deletions

View 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))

View 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.