Release comment as a release step; releasing grants implicit voice approval
Two corrections from the operator, and I had both wrong. 1. "I didn't explicitly approve all minor character voices. By releasing the audiobook I have given implicit approval those voices are ready and should be tagged." The previous session reported "24 of 61 speakers have an approval record; 38 do not" as if it were a gap. It is not a gap. Releasing IS the approval, and those voices were waiting to be tagged. chapter_approved and audiobook_approved now read true, with approved_by: "this publication record" - there is no separate audiobook approval in this system, and pretending otherwise understated the release. The boundary matters, so it is measured rather than assumed: implicit approval covers voices that HAVE LINES in the released chapters, computed from the database. A character who never speaks is not covered, because releasing a book does not approve a voice it never uses. On Deal Hunter edition 1 that is 24 explicitly approved, 29 implicitly approved, and 10 in-profile voices with no lines. The three categories are never summed - an explicitly approved voice can also be one that stopped speaking. grant_implicit_voice_approval() writes plain status strings, the shape the web UI already reads, and keeps the provenance in the immutable publication record rather than the mutable ledger, so it cannot be edited away. 2. The m4b inter-chapter gap should be the same 2 s as the lead and trail padding. export.py had gap_ms=4000 in one path and a default of 1000 in the other, while ar_export.SILENCE_MS = 2000 governed lead and trail: three drifting values for one concept. The gap now imports SILENCE_MS as CHAPTER_GAP_MS, so all three are one number that changes in one place. New umbraaudio/release_comments.py - the comment is a gate, not a caption. designator 2026-10-03 #2 14:32:05 slug release-20261003-143205-02 The sequence is per date, not global, which is the whole point: a problem found and corrected on the same day is unambiguous about which was last. Two comments authored in the same second still get distinct slugs, because the sequence is allocated by scanning the directory. First line is the summary, the rest is the body, like a commit message. An approved comment is FROZEN - changing an approved note is a different claim about a different state, so it needs a new comment and therefore a new publication. require_approved() blocks --publish: passing a draft exits 2 and says what to do next. Verified live. draft_comment_text() composes the comment from the measured build rather than from memory. Deal Hunter's draft is written and waiting: release-20261003-133508-01, 32 chapters, the 24/29/10 voice split, 32/32 loudness and peak gates green, and the post-approval padding note. New invariants R-8 through R-11 in spec 22. Two of my own test premises were wrong and the implementation was right - recorded in AGENTS.md because the tests were the weaker artefact. Full suite 538 passed / 8 failed, the same 8 pre-existing failures. ruff clean. Nothing was published: the Deal Hunter comment is a draft awaiting review, and voice_status.json is untouched because publishing never ran.
This commit is contained in:
46
AGENTS.md
46
AGENTS.md
@@ -171,6 +171,52 @@ Each pass has quality gates. Quality is the primary concern; generation speed is
|
||||
|
||||
## Current Session Summary
|
||||
|
||||
### Session: Release comments as a release step + implicit voice approval (spec 22 §1.1)
|
||||
- **Two corrections from the operator, both of which I had wrong:**
|
||||
1. *"I didn't explicitly approve all minor character voices. By releasing the audiobook I
|
||||
have given implicit approval those voices are ready and **should be tagged**."* The
|
||||
previous session reported "24 of 61 speakers have an approval record; 38 do not" as if
|
||||
it were a gap. It is not a gap — **releasing IS the approval**, and the ones with no
|
||||
record were waiting to be tagged. `chapter_approved`/`audiobook_approved` now read
|
||||
**true**, `approved_by: "this publication record"`, and publishing **grants** implicit
|
||||
approval to every voice a listener hears.
|
||||
2. The m4b inter-chapter gap should be the **same 2 s** as the lead/trail padding.
|
||||
- **The precise rule, and its boundary**: implicit approval covers voices that **have lines
|
||||
in the released chapters**, computed from the DB (`voices_used_in_release`), not every entry
|
||||
in `speakers.json`. A character who never speaks is **not** covered — releasing a book does
|
||||
not approve a voice it never uses. Measured on Deal Hunter ed. 1: **24 explicitly approved,
|
||||
29 implicitly approved, 10 in-profile voices with no lines (not covered)**. The three
|
||||
categories are never summed; an explicitly approved voice can also be one that stopped
|
||||
speaking. `grant_implicit_voice_approval()` writes plain status strings (the shape the web
|
||||
UI already reads) and keeps **provenance in the immutable publication record**, not in the
|
||||
mutable ledger, so it cannot be edited away.
|
||||
- **New `umbraaudio/release_comments.py`** — the release comment is a **gate**, not a caption.
|
||||
`designator = "2026-10-03 #2 14:32:05"`, `slug = release-20261003-143205-02`. The sequence
|
||||
is **per date, not global** — exactly so a same-day correction is unambiguous about which
|
||||
was last. Two comments in the same *second* still get distinct slugs (sequence allocated by
|
||||
scanning the dir). First line = summary, rest = body, like a commit message. **An approved
|
||||
comment is frozen**: editing one needs a new comment and therefore a new publication.
|
||||
`require_approved()` blocks `--publish` (exits 2 and says what to do next).
|
||||
- **`draft_comment_text()` composes the comment from the measured build**, so it states what
|
||||
shipped rather than what someone remembers shipping. **Deal Hunter's draft already exists**
|
||||
(`release-20261003-133508-01`, status DRAFT) summarising the last m4b build: 32 chapters,
|
||||
the 24/29/10 voice split, 32/32 loudness and peak gates green, and the 32-chapter
|
||||
post-approval padding note.
|
||||
- **One audible-pause value**: `assembly/export.py` had `gap_ms=4000` in one path and
|
||||
`chapter_gap_ms` defaulting to `1000` in the other, while `ar_export.SILENCE_MS = 2000`
|
||||
governed lead/trail — three drifting values for one concept. The m4b gap now **imports**
|
||||
`SILENCE_MS` as `CHAPTER_GAP_MS`, so all three are one number.
|
||||
- **Two of my own test premises were wrong and the implementation was right** — recorded
|
||||
because the tests were the weaker artefact: (a) two comments in the same second do **not**
|
||||
collide (the per-date sequence prevents it), so the test asserts distinct slugs instead of
|
||||
an error; (b) `parse_comment` strips leading blank lines, so a padded note cannot silently
|
||||
become a blank summary.
|
||||
- Tests: `tests/test_release_comments.py` (26) + `tests/test_release.py` extended to cover the
|
||||
three-way approval split. Full suite **538 passed / 8 failed** — the same 8 pre-existing/
|
||||
parallel failures. ruff clean on all new files.
|
||||
- **Nothing was published.** The Deal Hunter comment is a draft awaiting the operator's
|
||||
review and approval; `voice_status.json` is untouched because publishing never ran.
|
||||
|
||||
### Session: The publish flag, pinned release sets, and the Deal Hunter lesson (spec 22)
|
||||
- **Trigger**: operator clarification of the release model — the system stores current +
|
||||
previous state like git, there must be an **explicit publish flag** the user sets, there
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish a release: pin what shipped, then prove it (spec 22).
|
||||
"""Publish a release: author a comment, approve it, then pin what shipped (spec 22).
|
||||
|
||||
An approval exists for a speaker voice. There is no approval for a chapter and none for the
|
||||
audiobook, and Deal Hunter already paid for that gap: 2 s of dead sound was added to the
|
||||
start and end of every chapter *after* approval, so the approved artifact and the published
|
||||
artifact were different files.
|
||||
A release is a pinned set of artifacts plus an authored comment. The comment is a step, not
|
||||
a caption: it is authored, read, changed if wrong, and approved before anything can ship.
|
||||
Its designator is date + time + a per-date sequence (``2026-10-03 #2 14:32:05``) so two
|
||||
releases on one day are never ambiguous about which came last.
|
||||
|
||||
This script makes the release explicit.
|
||||
Releasing also grants **implicit approval** of every voice a listener hears in the audiobook.
|
||||
Those voices are tagged, with the provenance kept in the immutable publication record.
|
||||
|
||||
# What would ship, and what is missing? Read-only. Always do this first.
|
||||
# 1. see what would ship (read-only)
|
||||
python scripts/publish_release.py --project "DealHunterShip_4.0e_whole-to-audio"
|
||||
|
||||
# Write the immutable publication record (refuses to overwrite an existing one).
|
||||
python scripts/publish_release.py --project "..." --publish --by worstauthor
|
||||
# 2. author a comment from the measured build (a DRAFT; read it)
|
||||
python scripts/publish_release.py --project "..." --draft-comment --by worstauthor
|
||||
|
||||
# Re-verify a published record against the filesystem.
|
||||
python scripts/publish_release.py --project "..." --verify pub-001-2026-09-09
|
||||
# 3. change it, as many times as needed
|
||||
python scripts/publish_release.py --project "..." --edit-comment <slug> --text "..."
|
||||
|
||||
# List published editions.
|
||||
python scripts/publish_release.py --project "..." --list
|
||||
# 4. approve it
|
||||
python scripts/publish_release.py --project "..." --approve-comment <slug>
|
||||
|
||||
A change after publication is a NEW edition (``--edition 2 --supersedes pub-...``), never an
|
||||
edit of a released record.
|
||||
# 5. publish - pins hashes, tags implicitly approved voices
|
||||
python scripts/publish_release.py --project "..." --publish --comment <slug> --by you
|
||||
|
||||
# later: a fix is a NEW edition, never an edit
|
||||
python scripts/publish_release.py --project "..." --draft-comment --edition 2 \
|
||||
--supersedes pub-001-2026-09-09
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -35,11 +40,21 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from umbraaudio.config import load_config
|
||||
from umbraaudio.release import (
|
||||
build_publication,
|
||||
grant_implicit_voice_approval,
|
||||
list_publications,
|
||||
publication_path,
|
||||
verify_publication,
|
||||
write_publication,
|
||||
)
|
||||
from umbraaudio.release_comments import (
|
||||
CommentError,
|
||||
create_comment,
|
||||
draft_comment_text,
|
||||
list_comments,
|
||||
load_comment,
|
||||
require_approved,
|
||||
update_comment,
|
||||
)
|
||||
|
||||
|
||||
def _git_commit() -> str | None:
|
||||
@@ -48,44 +63,49 @@ def _git_commit() -> str | None:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
capture_output=True, text=True, timeout=10, check=False,
|
||||
)
|
||||
return out.stdout.strip() or None
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def _print_comment(comment) -> None: # noqa: ANN001 - ReleaseComment, avoids an import cycle
|
||||
print("RELEASE COMMENT")
|
||||
print(comment.render())
|
||||
|
||||
|
||||
def _print_summary(record: dict) -> None:
|
||||
profile = record["delivery_profile"]
|
||||
approvals = record["approvals"]
|
||||
comment = record.get("release_comment")
|
||||
print(f"publication_id {record['publication_id']}")
|
||||
print(f"work / edition {record['work']} #{record['edition_number']}"
|
||||
+ (f" (supersedes {record['supersedes']})" if record.get("supersedes") else ""))
|
||||
print(f"release comment {comment['designator']} [{comment['status']}]"
|
||||
if comment else "release comment NONE - a release needs an approved comment")
|
||||
print(f"chapters pinned {len(record['chapters'])}")
|
||||
pkg = record["package"]
|
||||
print(f"package {pkg['object_key']} {pkg['bytes']} bytes sha256="
|
||||
f"{(pkg['sha256'] or '')[:16]}…")
|
||||
print("delivery profile "
|
||||
f"{profile['sample_rate']} Hz / {profile['channels']} ch"
|
||||
print(f"package {pkg['object_key']} {pkg['bytes']} bytes "
|
||||
f"sha256={(pkg['sha256'] or '')[:16]}…")
|
||||
print(f"delivery profile {profile['sample_rate']} Hz / {profile['channels']} ch"
|
||||
+ (f" / {profile['bit_rate_kbps']} kbps" if profile.get("bit_rate_kbps") else "")
|
||||
+ f" silence +{profile['lead_silence_s']}s lead, +{profile['trail_silence_s']}s trail"
|
||||
+ f" +{profile['lead_silence_s']}s lead, +{profile['trail_silence_s']}s trail"
|
||||
f" rms {profile['target_rms_db']} dB")
|
||||
approvals = record["approvals"]
|
||||
print(f"approvals {approvals['voices_approved']} of "
|
||||
f"{approvals['voices_in_profile']} speakers have an approval record; "
|
||||
f"{approvals['voices_not_approved']} do not")
|
||||
print(" chapter approved: no | audiobook approved: no "
|
||||
"-> this record IS the release approval")
|
||||
print(f"approvals {approvals['voices_explicitly_approved']} voices explicitly "
|
||||
f"approved; {approvals['voices_implicitly_approved']} approved implicitly by this "
|
||||
f"release; {approvals['voices_in_profile_without_lines']} in the profile do not "
|
||||
"speak and are NOT covered")
|
||||
print(" chapter approved: yes | audiobook approved: yes "
|
||||
"-> granted by this record")
|
||||
|
||||
warned = [c for c in record["chapters"] if c["warnings"]]
|
||||
print(f"\npost-approval deltas detected on {len(warned)}/{len(record['chapters'])} chapters")
|
||||
for chapter in warned[:5]:
|
||||
print(f"\npost-approval deltas on {len(warned)}/{len(record['chapters'])} chapters")
|
||||
for chapter in warned[:3]:
|
||||
for warning in chapter["warnings"]:
|
||||
print(f" ch{chapter['chapter_id']:>3} {warning}")
|
||||
if len(warned) > 5:
|
||||
print(f" ... and {len(warned) - 5} more")
|
||||
if len(warned) > 3:
|
||||
print(f" ... and {len(warned) - 3} more")
|
||||
|
||||
if record["blockers"]:
|
||||
print(f"\nBLOCKERS ({len(record['blockers'])}) - not publishable:")
|
||||
@@ -97,13 +117,22 @@ def _print_summary(record: dict) -> None:
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--project", default=None, help="project name or path")
|
||||
parser.add_argument("--publish", action="store_true", help="write the immutable record")
|
||||
parser.add_argument("--verify", metavar="PUB_ID", default=None, help="verify a record")
|
||||
parser.add_argument("--list", action="store_true", help="list published editions")
|
||||
parser.add_argument("--project", default=None)
|
||||
parser.add_argument("--publish", action="store_true")
|
||||
parser.add_argument("--verify", metavar="PUB_ID", default=None)
|
||||
parser.add_argument("--list", action="store_true")
|
||||
parser.add_argument("--comments", action="store_true", help="list release comments")
|
||||
parser.add_argument("--show-comment", metavar="SLUG", default=None)
|
||||
parser.add_argument("--draft-comment", action="store_true",
|
||||
help="author a comment from the measured build (a DRAFT)")
|
||||
parser.add_argument("--edit-comment", metavar="SLUG", default=None)
|
||||
parser.add_argument("--approve-comment", metavar="SLUG", default=None)
|
||||
parser.add_argument("--comment", metavar="SLUG", default=None,
|
||||
help="the approved comment to publish with")
|
||||
parser.add_argument("--text", default=None, help="comment text (first line = summary)")
|
||||
parser.add_argument("--edition", type=int, default=1)
|
||||
parser.add_argument("--supersedes", default=None, help="publication_id this replaces")
|
||||
parser.add_argument("--by", default="operator", help="who is publishing")
|
||||
parser.add_argument("--supersedes", default=None)
|
||||
parser.add_argument("--by", default="operator")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -125,6 +154,79 @@ def main() -> int:
|
||||
+ (f" supersedes {r['supersedes']}" if r["supersedes"] else ""))
|
||||
return 0
|
||||
|
||||
if args.comments:
|
||||
rows = list_comments(data_dir)
|
||||
if args.json:
|
||||
print(json.dumps(rows, indent=2))
|
||||
elif not rows:
|
||||
print(f"no release comments for {work}")
|
||||
else:
|
||||
print(f"release comments for {work}")
|
||||
for r in rows:
|
||||
print(f" {r['designator']} [{r['status']:>8}] {r['slug']}")
|
||||
print(f" {r['summary']}")
|
||||
return 0
|
||||
|
||||
if args.show_comment:
|
||||
comment = load_comment(data_dir, args.show_comment)
|
||||
if comment is None:
|
||||
print(f"no such comment: {args.show_comment}", file=sys.stderr)
|
||||
return 1
|
||||
if args.json:
|
||||
print(json.dumps(comment.as_dict(), indent=2))
|
||||
else:
|
||||
_print_comment(comment)
|
||||
return 0
|
||||
|
||||
if args.approve_comment:
|
||||
try:
|
||||
comment = update_comment(
|
||||
data_dir, args.approve_comment, text=args.text, status="approved"
|
||||
)
|
||||
except CommentError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
print("approved:")
|
||||
_print_comment(comment)
|
||||
print(f"\npublish with:\n scripts/publish_release.py --project {cfg.project_dir.name} "
|
||||
f"--publish --comment {comment.slug} --by {args.by}")
|
||||
return 0
|
||||
|
||||
if args.edit_comment:
|
||||
if not args.text:
|
||||
print("--edit-comment needs --text", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
comment = update_comment(data_dir, args.edit_comment, text=args.text)
|
||||
except CommentError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
print("updated:")
|
||||
_print_comment(comment)
|
||||
return 0
|
||||
|
||||
if args.draft_comment:
|
||||
record = build_publication(
|
||||
data_dir, work, edition_number=args.edition, supersedes=args.supersedes
|
||||
)
|
||||
text = draft_comment_text(record, args.by)
|
||||
try:
|
||||
comment = create_comment(
|
||||
data_dir, text, author=args.by, facts={
|
||||
"chapters": len(record["chapters"]),
|
||||
"package_sha256": (record["package"] or {}).get("sha256"),
|
||||
"built_at": (record["package"] or {}).get("built_at"),
|
||||
"voices_implicitly_approved":
|
||||
record["approvals"]["voices_implicitly_approved"],
|
||||
},
|
||||
)
|
||||
except CommentError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
print("DRAFTED from the measured build. Read it, change it if it is wrong, then approve.")
|
||||
_print_comment(comment)
|
||||
return 0
|
||||
|
||||
if args.verify:
|
||||
path = publication_path(data_dir, args.verify)
|
||||
if not path.exists():
|
||||
@@ -144,6 +246,14 @@ def main() -> int:
|
||||
print(f" ... and {len(result['warnings']) - 10} more notes")
|
||||
return 0 if result["ok"] else 2
|
||||
|
||||
comment = None
|
||||
if args.comment:
|
||||
try:
|
||||
comment = require_approved(data_dir, args.comment)
|
||||
except CommentError as exc:
|
||||
print(f"refusing to publish: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
record = build_publication(
|
||||
data_dir,
|
||||
work,
|
||||
@@ -151,6 +261,7 @@ def main() -> int:
|
||||
published_by=args.by,
|
||||
git_commit=_git_commit(),
|
||||
supersedes=args.supersedes,
|
||||
comment=comment.as_dict() if comment else None,
|
||||
)
|
||||
|
||||
if not args.publish:
|
||||
@@ -158,18 +269,30 @@ def main() -> int:
|
||||
print(json.dumps(record, indent=2, sort_keys=True))
|
||||
else:
|
||||
_print_summary(record)
|
||||
print("\nDRY RUN - nothing written. Re-run with --publish to record it.")
|
||||
print("\nDRY RUN - nothing written.")
|
||||
if not comment:
|
||||
print("next: --draft-comment, read it, then --approve-comment <slug>")
|
||||
return 0 if record["publishable"] else 2
|
||||
|
||||
if not record["publishable"]:
|
||||
print("refusing to publish: blockers present (see above)", file=sys.stderr)
|
||||
print("refusing to publish: blockers present", file=sys.stderr)
|
||||
_print_summary(record)
|
||||
return 2
|
||||
|
||||
path = write_publication(data_dir, record)
|
||||
granted = grant_implicit_voice_approval(data_dir, record)
|
||||
|
||||
print(f"published -> {path}")
|
||||
print(f" this record is immutable. A later fix is edition "
|
||||
f"{args.edition + 1} with --supersedes {record['publication_id']}.")
|
||||
if comment:
|
||||
print(f" comment {comment.designator} ({comment.slug})")
|
||||
print(f" pinned {len(record['chapters'])} chapters + package by sha256")
|
||||
if granted["updated"]:
|
||||
print(f" implicit approval granted to {len(granted['updated'])} voice(s) "
|
||||
f"heard in this release: {', '.join(granted['updated'][:8])}"
|
||||
+ (" ..." if len(granted["updated"]) > 8 else ""))
|
||||
print(f" basis: {granted['basis']}; provenance kept in this record")
|
||||
print(f" next release edition {args.edition + 1} with "
|
||||
f"--supersedes {record['publication_id']} - this record is immutable.")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,24 @@ Five consequences, each implemented:
|
||||
5. **A change after publication is a successor, never an edit.** A published record is
|
||||
immutable; a fix produces edition *n+1* with `supersedes` pointing at *n*.
|
||||
|
||||
### 1.1 Follow-up clarifications, also implemented
|
||||
|
||||
> Not every minor character voice was explicitly approved. **By releasing the audiobook the
|
||||
> user has given implicit approval that those voices are ready, and they should be tagged.**
|
||||
> The dead audio gap between chapters in the m4b should be the **same 2 seconds**. At the
|
||||
> designated release, add a **comment** like a git commit comment — one the user can see,
|
||||
> change and approve. This **adds a step to the release**, makes it explicit, and gives a
|
||||
> designator to go back to. The comment should carry **date, time, and a sequence number for
|
||||
> that date (1, 2, 3)** so a problem found and corrected the same day makes it clear which
|
||||
> was last. Deal Hunter should receive the comment based on the last m4b build.
|
||||
|
||||
6. **Releasing approves the voices a listener hears.** The rest of the cast is *implicitly
|
||||
approved by the release* and is tagged. §4.3.
|
||||
7. **One audible-pause value.** The m4b inter-chapter gap is now the same 2 s as the lead and
|
||||
trail padding, imported from a single constant so they cannot diverge again. §4.4.
|
||||
8. **A release requires an authored, approved comment.** A gate, not a caption, with a
|
||||
date + time + per-date-sequence designator. §4.2.
|
||||
|
||||
## 2. What was already wrong, measured
|
||||
|
||||
Not hypothetical. On the published Deal Hunter package (`ar_package.json`,
|
||||
@@ -122,19 +140,76 @@ Two fields carry most of the weight and are worth calling out:
|
||||
"24 approved" as if it meant "all approved" would claim an approval that was never given,
|
||||
so the two counts are separate and the gap is named.
|
||||
|
||||
### 4.2 `scripts/publish_release.py`
|
||||
### 4.2 Release comments — `umbraaudio/release_comments.py`
|
||||
|
||||
```bash
|
||||
python scripts/publish_release.py --project "DealHunterShip_4.0e_whole-to-audio" # dry run
|
||||
python scripts/publish_release.py --project "..." --publish --by worstauthor # write
|
||||
python scripts/publish_release.py --project "..." --verify pub-001-2026-09-09 # re-check
|
||||
python scripts/publish_release.py --project "..." --list # editions
|
||||
A git-commit-style note with a designator you can quote later.
|
||||
|
||||
```
|
||||
designator 2026-10-03 #2 14:32:05
|
||||
slug release-20261003-143205-02
|
||||
author worstauthor
|
||||
status APPROVED
|
||||
|
||||
Deal Hunter edition 1: 32 chapters pinned
|
||||
|
||||
package export/Deal_Hunter.m4b (325875309 bytes, built 2026-09-09T11:21:11+00:00)
|
||||
delivery 44100 Hz / 1 ch / 192 kbps, +2.0s lead and +2.0s trail, -20.5 dB RMS
|
||||
```
|
||||
|
||||
Dry run is the default and prints the pinned set, the delivery profile, the approval gap, and
|
||||
**every post-approval delta it found**. `--publish` refuses to run while `blockers` is
|
||||
non-empty. A second `--publish` against the same id raises `FileExistsError` — immutability
|
||||
is enforced by the writer, not by convention.
|
||||
- **The sequence is per date, not global.** Two releases on one day are `#1` and `#2`, so
|
||||
"which was last" is answerable without ambiguity — the reason the designator exists. Two
|
||||
comments authored in the same *second* still get distinct slugs, because the sequence is
|
||||
allocated by scanning the directory.
|
||||
- **First line is the summary**, the rest is the body, exactly like a commit message.
|
||||
- **An approved comment is frozen.** Changing an approved note is a different claim about a
|
||||
different state, so it needs a new comment and a new publication — the same rule the
|
||||
publication itself follows.
|
||||
- **Publishing is gated on an approved comment.** `--publish` with a draft exits 2 and says
|
||||
what to do next.
|
||||
- `draft_comment_text()` composes the comment from the **measured build**, so it states what
|
||||
shipped rather than what someone remembers shipping. Deal Hunter's draft is written and
|
||||
waiting for review.
|
||||
|
||||
### 4.3 Implicit voice approval on release
|
||||
|
||||
Releasing the audiobook **is** the approval of every voice a listener hears in it. On publish,
|
||||
`grant_implicit_voice_approval()` tags those voices `approved` in `voice_status.json` and
|
||||
records the provenance — which publication, on what basis — inside the immutable publication
|
||||
record, where it cannot be edited away.
|
||||
|
||||
Measured on Deal Hunter, edition 1:
|
||||
|
||||
| category | count | meaning |
|
||||
|---|---|---|
|
||||
| explicitly approved | 24 | someone reviewed them in the UI |
|
||||
| **implicitly approved by this release** | **29** | they speak in it; releasing accepts them |
|
||||
| in the profile, no lines | 10 | **not covered** — releasing a book does not approve a voice it never uses |
|
||||
|
||||
Those three categories are deliberately never summed: an explicitly approved voice can also be
|
||||
one that no longer speaks. The ledger is written as plain status strings, the shape the web UI
|
||||
already reads, so no reader breaks.
|
||||
|
||||
### 4.4 One audible-pause value
|
||||
|
||||
`assembly/export.py` hard-coded `gap_ms=4000` in one path and defaulted to `1000` in the other,
|
||||
while the AR package used `SILENCE_MS = 2000` for lead and trail — three drifting values for
|
||||
one concept. The m4b inter-chapter gap now **imports** `SILENCE_MS`, so all three are one
|
||||
number that can only change in one place.
|
||||
|
||||
### 4.5 `scripts/publish_release.py`
|
||||
|
||||
```bash
|
||||
python scripts/publish_release.py --project "DealHunterShip_4.0e_whole-to-audio"
|
||||
python scripts/publish_release.py --project "..." --draft-comment --by worstauthor
|
||||
python scripts/publish_release.py --project "..." --edit-comment <slug> --text "..."
|
||||
python scripts/publish_release.py --project "..." --approve-comment <slug>
|
||||
python scripts/publish_release.py --project "..." --publish --comment <slug> --by you
|
||||
python scripts/publish_release.py --project "..." --verify pub-001-2026-10-03
|
||||
python scripts/publish_release.py --project "..." --comments
|
||||
python scripts/publish_release.py --project "..." --list
|
||||
```
|
||||
|
||||
A later fix is `--edition 2 --supersedes pub-001-2026-10-03`. Never an edit.
|
||||
|
||||
## 5. Invariants
|
||||
|
||||
@@ -145,11 +220,15 @@ is enforced by the writer, not by convention.
|
||||
| **R-3** | No publication occurs while a blocker exists | `publish_release.py --publish` exits 2 |
|
||||
| **R-4** | Any post-approval transform is recorded in the release | `delivery_profile` + per-chapter `warnings` |
|
||||
| **R-5** | A changed artifact after publication is reported as drift, never silently accepted | `verify_publication` → `drift` |
|
||||
| **R-6** | An approval is never inferred from the absence of a complaint | `approvals.chapter_approved = false` is recorded explicitly |
|
||||
| **R-6** | The publication record *is* the chapter and audiobook approval, and says so | `approvals.chapter_approved/audiobook_approved = true`, `approved_by: "this publication record"` |
|
||||
| **R-8** | No publication without an **approved release comment** | `require_approved()`; `--publish` with a draft exits 2 |
|
||||
| **R-9** | An approved comment is frozen; a changed note is a new release | `update_comment()` raises on edit-after-approve |
|
||||
| **R-10** | Implicit voice approval covers only voices that **have lines in the release** | `voices_used_in_release()`; unused profile entries are excluded and counted |
|
||||
| **R-11** | One audible-pause value across delivery paths | `CHAPTER_GAP_MS` imported from `ar_export.SILENCE_MS` |
|
||||
| **R-7** | The approval ledger's coverage is stated, not assumed | `voices_in_profile` vs `voices_approved` |
|
||||
|
||||
R-6 is the one that would have prevented Deal Hunter: the release says, in its own words,
|
||||
that no chapter and no audiobook was approved.
|
||||
R-6 and R-10 together are what Deal Hunter lacked: the release says, in its own words, that it
|
||||
*is* the chapter and audiobook approval, and exactly which voices that acceptance covered.
|
||||
|
||||
## 6. Relationship to the controller's model
|
||||
|
||||
@@ -163,6 +242,8 @@ This is the local, immutable half of what the controller calls `editions` + `edi
|
||||
| `delivery_profile` | `derivation` on each artifact |
|
||||
| `chapters[].source_unit_key_digest` | linkage back to `source_revisions` / `source_units` |
|
||||
| `approvals` | the acceptance record behind a release decision |
|
||||
| `release_comment` (slug + designator + text) | the human release note and its approval |
|
||||
| `approvals.voices_implicitly_approved_names` | which voices this release accepted, and why |
|
||||
| `published_at` / `published_by` | `released_at` / actor |
|
||||
|
||||
The record is shaped so ingest is a **copy**, not a transformation. Nothing the controller
|
||||
|
||||
@@ -32,7 +32,14 @@ def _wav(path: Path, seconds: float, rate: int = 24000) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def _project(tmp_path: Path, tracks: list[dict[str, Any]], fmt: dict | None = None) -> Path:
|
||||
def _project(
|
||||
tmp_path: Path,
|
||||
tracks: list[dict[str, Any]],
|
||||
fmt: dict | None = None,
|
||||
speakers: list[str] | None = None,
|
||||
ledger: dict[str, str] | None = None,
|
||||
dialogue: list[tuple[str, str]] | None = None,
|
||||
) -> Path:
|
||||
"""Build a minimal project tree with an exporter manifest."""
|
||||
data_dir = tmp_path / ".umbra"
|
||||
package_dir = data_dir / "export" / "ar_package"
|
||||
@@ -55,11 +62,34 @@ def _project(tmp_path: Path, tracks: list[dict[str, Any]], fmt: dict | None = No
|
||||
"tracks": tracks,
|
||||
}
|
||||
(data_dir / "export" / "ar_package.json").write_text(json.dumps(manifest))
|
||||
names = speakers if speakers is not None else ["Hallr", "Xylia", "Unreviewed"]
|
||||
(data_dir / "speakers.json").write_text(
|
||||
json.dumps({"speakers": [{"primary_name": "Hallr"}, {"primary_name": "Xylia"},
|
||||
{"primary_name": "Unreviewed"}]})
|
||||
json.dumps({"speakers": [{"primary_name": n} for n in names]})
|
||||
)
|
||||
(data_dir / "voice_status.json").write_text(json.dumps({"Hallr": "approved"}))
|
||||
entries = ledger if ledger is not None else {"Hallr": "approved"}
|
||||
(data_dir / "voice_status.json").write_text(json.dumps(entries))
|
||||
|
||||
# A real project DB so voice usage can be measured, not assumed.
|
||||
if dialogue is not None:
|
||||
chapter_id = tracks[0].get("chapter_id", 1)
|
||||
import sqlite3
|
||||
|
||||
con = sqlite3.connect(str(data_dir / "project.db"))
|
||||
con.executescript(
|
||||
"CREATE TABLE chapters (id INTEGER PRIMARY KEY, source_text TEXT);"
|
||||
"CREATE TABLE utterances (id INTEGER PRIMARY KEY, chapter_id INTEGER, "
|
||||
"utterance_index INTEGER, text TEXT, type TEXT DEFAULT 'narration', "
|
||||
"speaker_name TEXT);"
|
||||
)
|
||||
con.execute("INSERT INTO chapters (id, source_text) VALUES (?,?)", (chapter_id, "Body."))
|
||||
for i, (speaker, text) in enumerate(dialogue):
|
||||
con.execute(
|
||||
"INSERT INTO utterances (id, chapter_id, utterance_index, text, type, "
|
||||
"speaker_name) VALUES (?,?,?,?,'dialogue',?)",
|
||||
(i + 1, chapter_id, i, text, speaker),
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
return data_dir
|
||||
|
||||
|
||||
@@ -103,27 +133,42 @@ class TestBuildPublication:
|
||||
)
|
||||
assert record["package"]["track_count"] == 1
|
||||
|
||||
def test_approval_gap_is_reported_not_glossed(self, tmp_path: Path) -> None:
|
||||
"""voice_status.json only holds speakers someone touched. Saying '3 approved'
|
||||
would claim an approval that was never given."""
|
||||
data_dir = _project(tmp_path, [
|
||||
{"seq": 1, "title": "One", "file": "01.mp3", "source": "ch1_full.wav",
|
||||
"duration_s": 2.0, "chapter_seconds": 2.0},
|
||||
])
|
||||
def test_approval_is_split_explicit_implicit_and_unused(self, tmp_path: Path) -> None:
|
||||
"""The three categories must never be conflated.
|
||||
|
||||
Releasing approves every voice a listener HEARS. It does not approve a profile
|
||||
entry the release never uses.
|
||||
"""
|
||||
data_dir = _project(
|
||||
tmp_path,
|
||||
[{"seq": 1, "title": "One", "file": "01.mp3", "source": "ch1_full.wav",
|
||||
"duration_s": 2.0, "chapter_seconds": 2.0}],
|
||||
speakers=["Hallr", "Xylia", "NeverSpeaks"],
|
||||
ledger={"Hallr": "approved"},
|
||||
dialogue=[("Hallr", "Hello."), ("Xylia", "Goodbye.")],
|
||||
)
|
||||
approvals = build_publication(data_dir, "Book")["approvals"]
|
||||
assert approvals["voices_in_profile"] == 3
|
||||
assert approvals["voices_approved"] == 1
|
||||
assert approvals["voices_not_approved"] == 2
|
||||
assert "Unreviewed" in approvals["voices_not_approved_names"]
|
||||
assert approvals["voices_explicitly_approved"] == 1
|
||||
# Xylia speaks but was never explicitly reviewed -> implicit
|
||||
assert approvals["voices_implicitly_approved"] == 1
|
||||
assert approvals["voices_implicitly_approved_names"] == ["Xylia"]
|
||||
# NeverSpeaks is in the profile but has no lines -> not covered at all
|
||||
assert approvals["voices_in_profile_without_lines"] == 1
|
||||
assert approvals["voices_without_lines_names"] == ["NeverSpeaks"]
|
||||
assert approvals["implicit_approval_basis"] == "the act of releasing this audiobook"
|
||||
|
||||
def test_records_that_chapter_and_audiobook_are_unapproved(self, tmp_path: Path) -> None:
|
||||
def test_publication_record_is_the_chapter_and_audiobook_approval(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
data_dir = _project(tmp_path, [
|
||||
{"seq": 1, "title": "One", "file": "01.mp3", "source": "ch1_full.wav",
|
||||
"duration_s": 2.0, "chapter_seconds": 2.0},
|
||||
])
|
||||
approvals = build_publication(data_dir, "Book")["approvals"]
|
||||
assert approvals["chapter_approved"] is False
|
||||
assert approvals["audiobook_approved"] is False
|
||||
assert approvals["chapter_approved"] is True
|
||||
assert approvals["audiobook_approved"] is True
|
||||
assert approvals["approved_by"] == "this publication record"
|
||||
|
||||
|
||||
class TestPostApprovalDelta:
|
||||
|
||||
212
tests/test_release_comments.py
Normal file
212
tests/test_release_comments.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Tests for release comments - the explicit step in a release (spec 22).
|
||||
|
||||
The comment is a gate, and its designator has to make two releases on the same day
|
||||
unambiguous. Those are the two properties worth pinning.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from umbraaudio.release_comments import (
|
||||
STATUS_APPROVED,
|
||||
STATUS_DRAFT,
|
||||
CommentError,
|
||||
build_designator,
|
||||
build_slug,
|
||||
comment_path,
|
||||
create_comment,
|
||||
draft_comment_text,
|
||||
list_comments,
|
||||
load_comment,
|
||||
next_sequence,
|
||||
parse_comment,
|
||||
require_approved,
|
||||
update_comment,
|
||||
)
|
||||
|
||||
T0 = datetime(2026, 10, 3, 14, 32, 5, tzinfo=UTC)
|
||||
|
||||
|
||||
class TestDesignator:
|
||||
def test_slug_carries_date_time_and_sequence(self) -> None:
|
||||
assert build_slug(T0, 2) == "release-20261003-143205-02"
|
||||
|
||||
def test_designator_is_readable(self) -> None:
|
||||
assert build_designator(T0, 2) == "2026-10-03 #2 14:32:05"
|
||||
|
||||
def test_sequence_is_per_date_not_global(self, tmp_path: Path) -> None:
|
||||
create_comment(tmp_path, "First", now=T0)
|
||||
create_comment(tmp_path, "Second", now=T0 + timedelta(seconds=30))
|
||||
assert next_sequence(tmp_path, T0) == 3
|
||||
# A new date starts at 1 again, because the date is part of the handle.
|
||||
assert next_sequence(tmp_path, T0 + timedelta(days=1)) == 1
|
||||
|
||||
def test_same_day_sequence_disambiguates_two_releases(self, tmp_path: Path) -> None:
|
||||
a = create_comment(tmp_path, "First attempt", now=T0)
|
||||
b = create_comment(tmp_path, "Corrected attempt", now=T0 + timedelta(hours=1))
|
||||
assert a.sequence == 1 and b.sequence == 2
|
||||
assert a.date == b.date
|
||||
assert a.slug != b.slug
|
||||
assert "#2" in b.designator
|
||||
|
||||
def test_two_comments_in_the_same_second_still_get_distinct_slugs(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""The sequence is what makes this safe, not a timestamp collision check."""
|
||||
a = create_comment(tmp_path, "One", now=T0)
|
||||
b = create_comment(tmp_path, "Two", now=T0)
|
||||
assert a.slug != b.slug
|
||||
assert (a.sequence, b.sequence) == (1, 2)
|
||||
assert load_comment(tmp_path, a.slug).summary == "One"
|
||||
assert load_comment(tmp_path, b.slug).summary == "Two"
|
||||
|
||||
|
||||
class TestParseComment:
|
||||
def test_first_line_is_the_summary(self) -> None:
|
||||
summary, body = parse_comment("Fix the padding\n\n2s lead and trail.")
|
||||
assert summary == "Fix the padding"
|
||||
assert body == "2s lead and trail."
|
||||
|
||||
def test_body_only_comment(self) -> None:
|
||||
summary, body = parse_comment("Just a summary")
|
||||
assert body == ""
|
||||
|
||||
def test_empty_comment_is_refused(self) -> None:
|
||||
with pytest.raises(CommentError, match="at least a summary"):
|
||||
parse_comment(" \n \n")
|
||||
|
||||
def test_leading_blank_lines_are_ignored(self) -> None:
|
||||
"""Padding a note must not silently become the summary."""
|
||||
summary, body = parse_comment("\n\nShipped the fix\n\nDetail.")
|
||||
assert summary == "Shipped the fix"
|
||||
assert body == "Detail."
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
def test_new_comment_is_a_draft(self, tmp_path: Path) -> None:
|
||||
comment = create_comment(tmp_path, "Ship it", now=T0)
|
||||
assert comment.status == STATUS_DRAFT
|
||||
assert comment.approved_at is None
|
||||
|
||||
def test_draft_can_be_edited(self, tmp_path: Path) -> None:
|
||||
created = create_comment(tmp_path, "Wrong summary", now=T0)
|
||||
edited = update_comment(tmp_path, created.slug, text="Right summary\nwith detail")
|
||||
assert edited.summary == "Right summary"
|
||||
assert edited.body == "with detail"
|
||||
|
||||
def test_approved_comment_is_frozen(self, tmp_path: Path) -> None:
|
||||
created = create_comment(tmp_path, "Original", now=T0)
|
||||
update_comment(tmp_path, created.slug, status=STATUS_APPROVED)
|
||||
with pytest.raises(CommentError, match="frozen"):
|
||||
update_comment(tmp_path, created.slug, text="Changed my mind")
|
||||
# ...and the stored text is genuinely unchanged.
|
||||
assert load_comment(tmp_path, created.slug).summary == "Original"
|
||||
|
||||
def test_approve_records_the_time(self, tmp_path: Path) -> None:
|
||||
created = create_comment(tmp_path, "Ready", now=T0)
|
||||
approved = update_comment(tmp_path, created.slug, status=STATUS_APPROVED)
|
||||
assert approved.status == STATUS_APPROVED
|
||||
assert approved.approved_at is not None
|
||||
|
||||
def test_cannot_change_text_while_approving(self, tmp_path: Path) -> None:
|
||||
created = create_comment(tmp_path, "As drafted", now=T0)
|
||||
with pytest.raises(CommentError, match="Edit the draft first"):
|
||||
update_comment(tmp_path, created.slug, text="Different", status=STATUS_APPROVED)
|
||||
|
||||
def test_publishing_requires_an_approved_comment(self, tmp_path: Path) -> None:
|
||||
created = create_comment(tmp_path, "Draft only", now=T0)
|
||||
with pytest.raises(CommentError, match="DRAFT"):
|
||||
require_approved(tmp_path, created.slug)
|
||||
|
||||
def test_publishing_requires_a_comment_to_exist(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(CommentError, match="no release comment"):
|
||||
require_approved(tmp_path, "release-20261003-000000-01")
|
||||
|
||||
def test_approved_comment_satisfies_the_gate(self, tmp_path: Path) -> None:
|
||||
created = create_comment(tmp_path, "Good to go", now=T0)
|
||||
update_comment(tmp_path, created.slug, status=STATUS_APPROVED)
|
||||
assert require_approved(tmp_path, created.slug).slug == created.slug
|
||||
|
||||
def test_approved_at_creation_is_immediately_publishable(self, tmp_path: Path) -> None:
|
||||
created = create_comment(tmp_path, "Pre-approved", now=T0, status=STATUS_APPROVED)
|
||||
assert require_approved(tmp_path, created.slug).status == STATUS_APPROVED
|
||||
|
||||
|
||||
class TestStorage:
|
||||
def test_round_trips_through_disk(self, tmp_path: Path) -> None:
|
||||
created = create_comment(
|
||||
tmp_path, "Summary line\n\nBody text.", author="worstauthor", now=T0,
|
||||
facts={"chapters": 32},
|
||||
)
|
||||
loaded = load_comment(tmp_path, created.slug)
|
||||
assert loaded.summary == "Summary line"
|
||||
assert loaded.body == "Body text."
|
||||
assert loaded.facts["chapters"] == 32
|
||||
assert loaded.author == "worstauthor"
|
||||
|
||||
def test_listing_is_newest_first(self, tmp_path: Path) -> None:
|
||||
a = create_comment(tmp_path, "First", now=T0)
|
||||
b = create_comment(tmp_path, "Second", now=T0 + timedelta(minutes=5))
|
||||
rows = list_comments(tmp_path)
|
||||
assert [r["slug"] for r in rows] == [b.slug, a.slug]
|
||||
|
||||
def test_missing_comment_is_none_not_an_error(self, tmp_path: Path) -> None:
|
||||
assert load_comment(tmp_path, "release-19700101-000000-01") is None
|
||||
|
||||
def test_no_tmp_files_left_behind(self, tmp_path: Path) -> None:
|
||||
create_comment(tmp_path, "Clean write", now=T0)
|
||||
assert list(comment_path(tmp_path, "release-20261003-143205-01").parent.glob("*.tmp")) == []
|
||||
|
||||
def test_listing_empty_dir(self, tmp_path: Path) -> None:
|
||||
assert list_comments(tmp_path) == []
|
||||
|
||||
|
||||
class TestDraftFromBuild:
|
||||
def _record(self) -> dict:
|
||||
return {
|
||||
"work": "Deal Hunter",
|
||||
"edition_number": 1,
|
||||
"package": {
|
||||
"object_key": "export/Deal_Hunter.m4b",
|
||||
"bytes": 325875309,
|
||||
"built_at": "2026-09-09T11:21:11+00:00",
|
||||
"sha256": "abc",
|
||||
},
|
||||
"delivery_profile": {
|
||||
"sample_rate": 44100, "channels": 1, "bit_rate_kbps": 192,
|
||||
"lead_silence_s": 2.0, "trail_silence_s": 2.0, "target_rms_db": -20.5,
|
||||
},
|
||||
"approvals": {
|
||||
"voices_explicitly_approved": 24,
|
||||
"voices_implicitly_approved": 29,
|
||||
"voices_in_profile_without_lines": 10,
|
||||
},
|
||||
"chapters": [
|
||||
{"mp3": {"loudness_ok": True, "peak_ok": True}, "warnings": ["4.0s longer"]},
|
||||
{"mp3": {"loudness_ok": True, "peak_ok": True}, "warnings": []},
|
||||
],
|
||||
}
|
||||
|
||||
def test_comment_states_the_measured_facts(self) -> None:
|
||||
text = draft_comment_text(self._record(), "worstauthor")
|
||||
assert "Deal Hunter edition 1" in text
|
||||
assert "2 chapters pinned" in text
|
||||
assert "44100 Hz" in text
|
||||
assert "+2.0s lead" in text
|
||||
assert "24 explicitly approved" in text
|
||||
assert "29 approved implicitly" in text
|
||||
|
||||
def test_comment_names_the_post_approval_delta(self) -> None:
|
||||
text = draft_comment_text(self._record(), "worstauthor")
|
||||
assert "1 chapters shipped longer or resampled" in text
|
||||
|
||||
def test_comment_is_first_line_summary(self) -> None:
|
||||
summary, _ = parse_comment(draft_comment_text(self._record(), "x"))
|
||||
assert "Deal Hunter" in summary
|
||||
@@ -8,6 +8,14 @@ from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Inter-chapter gap inside the m4b. Kept equal to the AR package's leading/trailing silence
|
||||
# (ar_export.SILENCE_MS) so every delivery uses one audible-pause value instead of three
|
||||
# drifting ones. Imported rather than re-declared so the two cannot diverge again.
|
||||
try:
|
||||
from umbraaudio.assembly.ar_export import SILENCE_MS as CHAPTER_GAP_MS
|
||||
except Exception: # pragma: no cover - ar_export pulls numpy/soundfile
|
||||
CHAPTER_GAP_MS = 2000
|
||||
|
||||
|
||||
class AudiobookExporter:
|
||||
def export_m4b(
|
||||
@@ -38,7 +46,7 @@ class AudiobookExporter:
|
||||
concat_wav = chapter_wavs[0]
|
||||
else:
|
||||
concat_wav = tmp_dir / "concatenated.wav"
|
||||
self._concat_wavs_with_silence(chapter_wavs, concat_wav, gap_ms=4000)
|
||||
self._concat_wavs_with_silence(chapter_wavs, concat_wav, gap_ms=CHAPTER_GAP_MS)
|
||||
|
||||
m4b_no_meta = tmp_dir / "no_meta.m4b"
|
||||
self._encode_aac_m4b(concat_wav, m4b_no_meta)
|
||||
@@ -136,7 +144,9 @@ class AudiobookExporter:
|
||||
|
||||
# --- 2) Concatenate chapters with a small gap --------------------------
|
||||
concat_wav = tmp_dir / "concatenated.wav"
|
||||
self._concat_wavs_with_silence(staged, concat_wav, gap_ms=int(meta.get("chapter_gap_ms", 1000)))
|
||||
self._concat_wavs_with_silence(
|
||||
staged, concat_wav, gap_ms=int(meta.get("chapter_gap_ms", CHAPTER_GAP_MS))
|
||||
)
|
||||
|
||||
# --- 3) Encode AAC M4B ------------------------------------------------
|
||||
m4b_no_meta = tmp_dir / "no_meta.m4b"
|
||||
|
||||
@@ -252,10 +252,12 @@ def build_publication(
|
||||
git_commit: str | None = None,
|
||||
supersedes: str | None = None,
|
||||
now: str | None = None,
|
||||
comment: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Capture the pinned set, the approvals in force, and the delivery profile.
|
||||
|
||||
Read-only with respect to the project: hashes files, writes nothing.
|
||||
Read-only with respect to the project: hashes files, writes nothing. ``comment`` is the
|
||||
approved release comment to embed; the CLI refuses to publish without one.
|
||||
"""
|
||||
data_dir = Path(data_dir)
|
||||
export_dir = data_dir / "export"
|
||||
@@ -336,7 +338,9 @@ def build_publication(
|
||||
)
|
||||
|
||||
voice_status = _read_json(data_dir / "voice_status.json")
|
||||
approvals = _approval_block(voice_status, data_dir)
|
||||
chapter_ids = sorted({c.chapter_id for c in chapters if c.chapter_id >= 0})
|
||||
used_voices = voices_used_in_release(data_dir, chapter_ids)
|
||||
approvals = _approval_block(voice_status, data_dir, used_voices)
|
||||
|
||||
package_file = export_dir / f"{package.get('base_name', work)}.m4b"
|
||||
if not package_file.exists():
|
||||
@@ -364,59 +368,162 @@ def build_publication(
|
||||
"built_at": package.get("built_at"),
|
||||
},
|
||||
"approvals": approvals,
|
||||
"release_comment": comment,
|
||||
"chapters": [c.as_dict() for c in chapters],
|
||||
"blockers": blockers,
|
||||
"publishable": not blockers,
|
||||
}
|
||||
|
||||
|
||||
def _approval_block(voice_status: dict[str, Any], data_dir: Path) -> dict[str, Any]:
|
||||
"""The per-item approvals that were in force, pinned by digest so they are auditable.
|
||||
def voices_used_in_release(data_dir: Path, chapter_ids: list[int]) -> set[str]:
|
||||
"""Canonical speaker names that actually have lines in the released chapters.
|
||||
|
||||
``voice_status.json`` is an *approval ledger*: it only holds speakers someone explicitly
|
||||
touched, so it is smaller than ``speakers.json``. Reporting its size as "voices approved"
|
||||
would imply an approval that was never given, so the two counts are reported separately
|
||||
and the gap is named.
|
||||
Implicit approval covers the voices a listener HEARS in this release, not every entry
|
||||
in ``speakers.json``. A character who never speaks in the released chapters is not
|
||||
covered by releasing it, and pretending otherwise would overstate the approval.
|
||||
"""
|
||||
raw = voice_status.get("speakers", voice_status) if isinstance(voice_status, dict) else {}
|
||||
entries: dict[str, Any] = raw if isinstance(raw, dict) else {}
|
||||
statuses = {
|
||||
name: (value.get("status") if isinstance(value, dict) else value)
|
||||
for name, value in entries.items()
|
||||
}
|
||||
approved = sorted(n for n, s in statuses.items() if str(s).lower() == "approved")
|
||||
import sqlite3
|
||||
|
||||
profiles = _read_json(data_dir / "speakers.json").get("speakers", []) or []
|
||||
in_profile = {
|
||||
db_path = Path(data_dir) / "project.db"
|
||||
if not db_path.exists() or not chapter_ids:
|
||||
return set()
|
||||
profiles = _read_json(Path(data_dir) / "speakers.json").get("speakers", []) or []
|
||||
alias: dict[str, str] = {}
|
||||
for profile in profiles:
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
primary = str(profile.get("primary_name", ""))
|
||||
if primary:
|
||||
alias[primary.casefold()] = primary
|
||||
for extra in profile.get("aliases") or []:
|
||||
if extra:
|
||||
alias[str(extra).casefold()] = primary
|
||||
|
||||
placeholders = ",".join("?" * len(chapter_ids))
|
||||
con = sqlite3.connect(f"file:{db_path.resolve()}?mode=ro", uri=True)
|
||||
try:
|
||||
rows = con.execute(
|
||||
f"SELECT DISTINCT speaker_name FROM utterances "
|
||||
f"WHERE lower(type)='dialogue' AND chapter_id IN ({placeholders}) "
|
||||
f"AND speaker_name IS NOT NULL",
|
||||
chapter_ids,
|
||||
).fetchall()
|
||||
finally:
|
||||
con.close()
|
||||
used: set[str] = set()
|
||||
for (name,) in rows:
|
||||
clean = str(name).strip()
|
||||
if not clean or clean.casefold() in ("narrator", "the narrator"):
|
||||
continue
|
||||
used.add(alias.get(clean.casefold(), clean))
|
||||
return used
|
||||
|
||||
|
||||
def _profile_names(data_dir: Path) -> set[str]:
|
||||
profiles = _read_json(Path(data_dir) / "speakers.json").get("speakers", []) or []
|
||||
return {
|
||||
str(p.get("primary_name", ""))
|
||||
for p in profiles
|
||||
if isinstance(p, dict) and p.get("primary_name")
|
||||
}
|
||||
# A speaker counts as approved only if the ledger says so by primary name.
|
||||
unapproved = sorted(in_profile - set(approved))
|
||||
orphans = sorted(set(approved) - in_profile)
|
||||
|
||||
|
||||
def _approval_block(voice_status: dict[str, Any], data_dir: Path, used: set[str]) -> dict[str, Any]:
|
||||
"""Record the approvals in force, separating what was explicit from what the release grants.
|
||||
|
||||
``voice_status.json`` is an *approval ledger*: it only holds voices someone explicitly
|
||||
reviewed. The rest of the cast is approved **implicitly by the act of releasing the
|
||||
audiobook** - releasing the book means accepting every voice a listener hears in it.
|
||||
This block makes both visible, and keeps "unused" separate from "implicitly approved":
|
||||
a profile entry that never speaks is not covered by the release.
|
||||
"""
|
||||
raw = voice_status.get("speakers", voice_status) if isinstance(voice_status, dict) else {}
|
||||
entries: dict[str, Any] = raw if isinstance(raw, dict) else {}
|
||||
statuses = {
|
||||
str(name): str(value.get("status") if isinstance(value, dict) else value)
|
||||
for name, value in entries.items()
|
||||
}
|
||||
explicitly = sorted(n for n, s in statuses.items() if s.casefold() == "approved")
|
||||
|
||||
in_profile = _profile_names(data_dir)
|
||||
used_in_profile = used & in_profile
|
||||
implicitly = sorted(n for n in used_in_profile if n not in explicitly)
|
||||
unused = sorted(in_profile - used_in_profile)
|
||||
unprofiled_used = sorted(used - in_profile)
|
||||
|
||||
return {
|
||||
"voices_in_profile": len(in_profile),
|
||||
"voices_with_approval_record": len(approved),
|
||||
"voices_approved": len(approved),
|
||||
"voices_not_approved": len(unapproved),
|
||||
"voices_not_approved_names": unapproved,
|
||||
"approval_ledger_entries_without_profile": orphans,
|
||||
"voices_explicitly_approved": len(explicitly),
|
||||
"voices_explicitly_approved_names": explicitly,
|
||||
"voices_explicitly_approved_and_used": len(set(explicitly) & used_in_profile),
|
||||
"voices_used_in_release": len(used_in_profile),
|
||||
"voices_implicitly_approved": len(implicitly),
|
||||
"voices_implicitly_approved_names": implicitly,
|
||||
"implicit_approval_basis": "the act of releasing this audiobook",
|
||||
"voices_in_profile_without_lines": len(unused),
|
||||
"voices_without_lines_names": unused,
|
||||
"voices_used_without_profile": unprofiled_used,
|
||||
"voice_status_digest": _digest_of_strings(
|
||||
[f"{n}={s}" for n, s in sorted(statuses.items())]
|
||||
),
|
||||
"chapter_approved": False,
|
||||
"audiobook_approved": False,
|
||||
# The publication record IS the chapter and audiobook approval.
|
||||
"chapter_approved": True,
|
||||
"audiobook_approved": True,
|
||||
"approved_by": "this publication record",
|
||||
"_note": (
|
||||
"voice_status.json holds per-speaker approvals only, and only for speakers "
|
||||
"someone explicitly touched - it is smaller than speakers.json. There is no "
|
||||
"chapter or audiobook approval in the system today; this record IS the "
|
||||
"release approval."
|
||||
"Voices a listener hears are approved by releasing the audiobook; those "
|
||||
"without an explicit voice_status.json entry are tagged implicitly by this "
|
||||
"record. Chapter and audiobook approval ARE this record - there is no "
|
||||
"separate audiobook approval in the system. Profile entries that never have "
|
||||
"lines are NOT covered: releasing a book does not approve a voice it never uses."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def grant_implicit_voice_approval(
|
||||
data_dir: Path, record: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Tag every voice used in the release as approved. Returns what it changed.
|
||||
|
||||
Writes ``voice_status.json`` values as plain status strings, the shape the web UI
|
||||
already reads (``voice_status.get(name, "pending")``). The **provenance** - which
|
||||
publication granted this, under which comment - stays in the immutable publication
|
||||
record rather than in the mutable ledger, so it cannot be edited away.
|
||||
"""
|
||||
approvals = record.get("approvals", {}) or {}
|
||||
names = list(approvals.get("voices_implicitly_approved_names", []) or [])
|
||||
if not names:
|
||||
return {"updated": [], "already_approved": [], "granted_by": None}
|
||||
|
||||
path = Path(data_dir) / "voice_status.json"
|
||||
raw = _read_json(path)
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
if isinstance(raw.get("speakers"), dict):
|
||||
raw = raw["speakers"]
|
||||
|
||||
updated: list[str] = []
|
||||
already: list[str] = []
|
||||
for name in names:
|
||||
if str(raw.get(name, "")).casefold() == "approved":
|
||||
already.append(name)
|
||||
continue
|
||||
raw[name] = "approved"
|
||||
updated.append(name)
|
||||
|
||||
if updated:
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
return {
|
||||
"updated": sorted(updated),
|
||||
"already_approved": sorted(already),
|
||||
"granted_by": record.get("publication_id"),
|
||||
"basis": approvals.get("implicit_approval_basis"),
|
||||
}
|
||||
|
||||
|
||||
def verify_publication(data_dir: Path, record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Re-check a publication against the filesystem.
|
||||
|
||||
|
||||
344
umbraaudio/release_comments.py
Normal file
344
umbraaudio/release_comments.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""Release comments: the explicit step between "ready" and "published".
|
||||
|
||||
A release comment is the human-authored note that accompanies a publish, in the spirit of a
|
||||
git commit message: a short summary line, an optional body, an author, and a designator you
|
||||
can quote later when something needs to go back to something.
|
||||
|
||||
Two properties make it useful rather than decorative:
|
||||
|
||||
1. **The designator is date + time + a per-date sequence.** ``2026-10-03 #2 14:32:05``. If a
|
||||
problem is found and corrected the same day, the sequence makes it unambiguous which
|
||||
attempt was last. Without it, two releases on one date are indistinguishable in a
|
||||
conversation.
|
||||
|
||||
2. **It is a gate, not a caption.** A publication cannot be written without an *approved*
|
||||
comment. An approved comment is frozen: editing one requires a new comment and therefore
|
||||
a new publication, which is the same rule the publication itself follows.
|
||||
|
||||
Lifecycle::
|
||||
|
||||
draft ──approve──▶ approved ──publish──▶ frozen inside the publication
|
||||
▲
|
||||
└── edit freely while it is still a draft
|
||||
|
||||
Storage is one JSON file per comment under ``.umbra/release_comments/``, named
|
||||
``release-<YYYYMMDD>-<HHMMSS>-<NN>.json``. The publication record holds a *copy* of the
|
||||
approved comment, so a publication stays readable even if the comment directory is ever
|
||||
tidied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
STATUS_DRAFT = "draft"
|
||||
STATUS_APPROVED = "approved"
|
||||
|
||||
COMMENT_DIR = "release_comments"
|
||||
|
||||
_SLUG_RE = re.compile(r"^release-(\d{8})-(\d{6})-(\d{2,})$")
|
||||
|
||||
|
||||
class CommentError(RuntimeError):
|
||||
"""Raised when a comment operation would violate the release rules."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseComment:
|
||||
"""One authored release note."""
|
||||
|
||||
slug: str
|
||||
designator: str
|
||||
date: str
|
||||
time: str
|
||||
sequence: int
|
||||
summary: str
|
||||
body: str
|
||||
author: str
|
||||
authored_at: str
|
||||
status: str = STATUS_DRAFT
|
||||
approved_at: str | None = None
|
||||
facts: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> ReleaseComment:
|
||||
return cls(
|
||||
slug=data["slug"],
|
||||
designator=data["designator"],
|
||||
date=data["date"],
|
||||
time=data["time"],
|
||||
sequence=int(data["sequence"]),
|
||||
summary=data.get("summary", ""),
|
||||
body=data.get("body", ""),
|
||||
author=data.get("author", "operator"),
|
||||
authored_at=data.get("authored_at", ""),
|
||||
status=data.get("status", STATUS_DRAFT),
|
||||
approved_at=data.get("approved_at"),
|
||||
facts=data.get("facts", {}) or {},
|
||||
)
|
||||
|
||||
def render(self) -> str:
|
||||
"""The block a human reads, edits and approves."""
|
||||
lines = [
|
||||
f" designator {self.designator}",
|
||||
f" slug {self.slug}",
|
||||
f" author {self.author}",
|
||||
f" authored {self.authored_at}",
|
||||
f" status {self.status.upper()}",
|
||||
]
|
||||
if self.approved_at:
|
||||
lines.append(f" approved {self.approved_at}")
|
||||
lines.append("")
|
||||
lines.append(f" {self.summary}")
|
||||
if self.body:
|
||||
lines.append("")
|
||||
for para in self.body.splitlines():
|
||||
lines.append(f" {para}" if para else "")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def comments_dir(data_dir: Path) -> Path:
|
||||
return Path(data_dir) / COMMENT_DIR
|
||||
|
||||
|
||||
def comment_path(data_dir: Path, slug: str) -> Path:
|
||||
return comments_dir(data_dir) / f"{slug}.json"
|
||||
|
||||
|
||||
def parse_comment(text: str) -> tuple[str, str]:
|
||||
"""Split authored text into (summary, body).
|
||||
|
||||
The first non-empty line is the summary, like a commit subject. Everything after it is
|
||||
the body. A first line longer than 72 characters is accepted but reported as a weak
|
||||
summary rather than silently truncated, because truncating someone's note is worse than
|
||||
telling them it is long.
|
||||
"""
|
||||
lines = text.strip().splitlines()
|
||||
if not lines:
|
||||
raise CommentError("a release comment needs at least a summary line")
|
||||
summary = lines[0].strip()
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
if not summary:
|
||||
raise CommentError("the first line of a release comment is the summary and cannot be empty")
|
||||
return summary, body
|
||||
|
||||
|
||||
def _existing_sequences(data_dir: Path, date_compact: str) -> list[int]:
|
||||
directory = comments_dir(data_dir)
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
found: list[int] = []
|
||||
for path in directory.glob(f"release-{date_compact}-*.json"):
|
||||
match = _SLUG_RE.match(path.stem)
|
||||
if match:
|
||||
found.append(int(match.group(3)))
|
||||
return found
|
||||
|
||||
|
||||
def next_sequence(data_dir: Path, when: datetime) -> int:
|
||||
"""1-based sequence for this date. Two releases on one day are never ambiguous."""
|
||||
return max(_existing_sequences(data_dir, when.strftime("%Y%m%d")), default=0) + 1
|
||||
|
||||
|
||||
def build_slug(when: datetime, sequence: int) -> str:
|
||||
return f"release-{when.strftime('%Y%m%d')}-{when.strftime('%H%M%S')}-{sequence:02d}"
|
||||
|
||||
|
||||
def build_designator(when: datetime, sequence: int) -> str:
|
||||
"""``2026-10-03 #2 14:32:05`` - readable, and quotable when going back to something."""
|
||||
return f"{when.strftime('%Y-%m-%d')} #{sequence} {when.strftime('%H:%M:%S')}"
|
||||
|
||||
|
||||
def create_comment(
|
||||
data_dir: Path,
|
||||
text: str,
|
||||
author: str = "operator",
|
||||
now: datetime | None = None,
|
||||
status: str = STATUS_DRAFT,
|
||||
facts: dict[str, Any] | None = None,
|
||||
) -> ReleaseComment:
|
||||
"""Author a new comment. Refuses an empty note."""
|
||||
summary, body = parse_comment(text)
|
||||
when = now or datetime.now(UTC)
|
||||
sequence = next_sequence(data_dir, when)
|
||||
comment = ReleaseComment(
|
||||
slug=build_slug(when, sequence),
|
||||
designator=build_designator(when, sequence),
|
||||
date=when.strftime("%Y-%m-%d"),
|
||||
time=when.strftime("%H:%M:%S"),
|
||||
sequence=sequence,
|
||||
summary=summary,
|
||||
body=body,
|
||||
author=author,
|
||||
authored_at=when.isoformat(timespec="seconds"),
|
||||
status=status,
|
||||
approved_at=when.isoformat(timespec="seconds") if status == STATUS_APPROVED else None,
|
||||
facts=facts or {},
|
||||
)
|
||||
path = comment_path(data_dir, comment.slug)
|
||||
if path.exists():
|
||||
# Same second, same sequence. Never silently overwrite someone's note.
|
||||
raise CommentError(
|
||||
f"{comment.slug} already exists. Wait a second or edit the existing comment."
|
||||
)
|
||||
_write_json(path, comment.as_dict())
|
||||
return comment
|
||||
|
||||
|
||||
def load_comment(data_dir: Path, slug: str) -> ReleaseComment | None:
|
||||
path = comment_path(data_dir, slug)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return ReleaseComment.from_dict(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def list_comments(data_dir: Path) -> list[dict[str, Any]]:
|
||||
"""Every comment, newest first. Read-only."""
|
||||
directory = comments_dir(data_dir)
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for path in sorted(directory.glob("release-*.json"), reverse=True):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"slug": data.get("slug"),
|
||||
"designator": data.get("designator"),
|
||||
"date": data.get("date"),
|
||||
"sequence": data.get("sequence"),
|
||||
"author": data.get("author"),
|
||||
"status": data.get("status"),
|
||||
"summary": data.get("summary"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def update_comment(
|
||||
data_dir: Path,
|
||||
slug: str,
|
||||
text: str | None = None,
|
||||
status: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> ReleaseComment:
|
||||
"""Edit a draft, or approve it.
|
||||
|
||||
An **approved** comment is frozen. Changing an approved note is not editing - it is a
|
||||
different claim about a different state, so it needs a new comment and a new
|
||||
publication. This is the same rule the publication itself follows, deliberately.
|
||||
"""
|
||||
comment = load_comment(data_dir, slug)
|
||||
if comment is None:
|
||||
raise CommentError(f"no such release comment: {slug}")
|
||||
|
||||
when = now or datetime.now(UTC)
|
||||
|
||||
if status == STATUS_APPROVED:
|
||||
if text is not None:
|
||||
summary, body = parse_comment(text)
|
||||
if (summary, body) != (comment.summary, comment.body):
|
||||
raise CommentError(
|
||||
"cannot change the text of a comment while approving it. Edit the "
|
||||
"draft first, then approve."
|
||||
)
|
||||
if comment.status != STATUS_APPROVED:
|
||||
comment.status = STATUS_APPROVED
|
||||
comment.approved_at = when.isoformat(timespec="seconds")
|
||||
elif text is not None:
|
||||
if comment.status == STATUS_APPROVED:
|
||||
raise CommentError(
|
||||
f"{comment.slug} is approved and frozen. Write a new comment: a changed "
|
||||
"note is a new release, not an edit of this one."
|
||||
)
|
||||
comment.summary, comment.body = parse_comment(text)
|
||||
|
||||
_write_json(comment_path(data_dir, slug), comment.as_dict())
|
||||
return comment
|
||||
|
||||
|
||||
def require_approved(data_dir: Path, slug: str) -> ReleaseComment:
|
||||
"""The publication gate. Raises if the comment is missing or still a draft."""
|
||||
comment = load_comment(data_dir, slug)
|
||||
if comment is None:
|
||||
raise CommentError(
|
||||
f"no release comment {slug}. A release needs an approved comment: author one "
|
||||
"with --comment, then --approve-comment."
|
||||
)
|
||||
if comment.status != STATUS_APPROVED:
|
||||
raise CommentError(
|
||||
f"release comment {comment.designator} is a DRAFT. Read it, change it if it "
|
||||
"is wrong, then --approve-comment."
|
||||
)
|
||||
return comment
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def draft_comment_text(record: dict[str, Any], author: str) -> str:
|
||||
"""Compose a factual release comment from what was actually measured.
|
||||
|
||||
This is the Deal Hunter case: the comment is derived from the real build rather than
|
||||
written from memory, so it says what shipped, under which profile, with which gates,
|
||||
and which approvals were granted implicitly by the act of releasing.
|
||||
"""
|
||||
profile = record.get("delivery_profile", {}) or {}
|
||||
approvals = record.get("approvals", {}) or {}
|
||||
chapters = record.get("chapters", []) or []
|
||||
deltas = [c for c in chapters if c.get("warnings")]
|
||||
|
||||
summary = (
|
||||
f"{record.get('work')} edition {record.get('edition_number')}: "
|
||||
f"{len(chapters)} chapters pinned"
|
||||
+ (f", {len(deltas)} with post-approval delivery deltas" if deltas else "")
|
||||
)
|
||||
|
||||
loud_ok = sum(1 for c in chapters if (c.get("mp3") or {}).get("loudness_ok"))
|
||||
peak_ok = sum(1 for c in chapters if (c.get("mp3") or {}).get("peak_ok"))
|
||||
lines = [
|
||||
summary,
|
||||
"",
|
||||
f"package {(record.get('package') or {}).get('object_key')} "
|
||||
f"({(record.get('package') or {}).get('bytes')} bytes, built "
|
||||
f"{(record.get('package') or {}).get('built_at')})",
|
||||
f"delivery {profile.get('sample_rate')} Hz / {profile.get('channels')} ch"
|
||||
+ (f" / {profile.get('bit_rate_kbps')} kbps" if profile.get("bit_rate_kbps") else "")
|
||||
+ f", +{profile.get('lead_silence_s')}s lead and +{profile.get('trail_silence_s')}s "
|
||||
f"trail silence, {profile.get('target_rms_db')} dB RMS target",
|
||||
f"voices {approvals.get('voices_explicitly_approved', 0)} explicitly approved; "
|
||||
f"{approvals.get('voices_implicitly_approved', 0)} approved implicitly by this "
|
||||
f"release (they speak in it); "
|
||||
f"{approvals.get('voices_in_profile_without_lines', 0)} in the profile are not "
|
||||
"covered because they do not speak",
|
||||
f"gates loudness_ok on {loud_ok}/{len(chapters)}, "
|
||||
f"peak_ok on {peak_ok}/{len(chapters)}",
|
||||
]
|
||||
if deltas:
|
||||
lines += [
|
||||
"",
|
||||
f"NOTE: {len(deltas)} chapters shipped longer or resampled than the reviewed "
|
||||
"chapter wav. The release pins the DELIVERED bytes; the per-chapter review "
|
||||
"covered the pre-delivery artifact. See delivery_profile.",
|
||||
]
|
||||
return "\n".join(lines) + f"\n\nAuthored by {author} from the measured build."
|
||||
Reference in New Issue
Block a user