RFC-019 · Science Overlay & Episode System — architecture, pipeline, cost analysis
Status: Draft v0.4 · 2026-05-16 (12 architectural decisions resolved) · Closes: PRD-016
Why this is an RFC. The architecture binds every future audio episode and every route's player surface for years: TTS provider abstraction (so "swap providers" is an env var, not a rewrite), bundle / hosting strategy that survives the planned VPS docker-compose migration at v1.0, async generation that runs identically on Marko's M-series Mac and in GH Actions, and a translation pipeline that keeps 12-locale parity without leaking executable code into mobile builds (App Store §2.5.2). These are interlocking decisions; one wrong cut early forces ugly retrofits later.
1 · Architecture overview
┌──────────────────────┐
│ content/episodes/ │ ← human-written markdown + SSML, en-US source
│ en-US/{id}.md │
└──────────┬───────────┘
│
▼
┌────────────────────────┐ ┌──────────────────┐
│ Pipeline 1 (i18n) │────────▶│ content/episodes/│
│ Claude API │ │ {locale}/{id}.md│
│ SSML-safe translate │ └──────────────────┘
└────────────────────────┘ │
▼
┌─────────────────────┐
│ Pipeline 2 (TTS) │
│ TtsProvider iface │
│ hash-keyed cache │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ static/audio/... │
│ {locale}/{persona}/{id}.mp3│
│ {locale}/{persona}/{id}.vtt│
│ {locale}/{persona}/{id}.txt│
└──────────┬──────────┘
│
┌────────────────────────────┴────────────────────────────┐
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ Web build (GH Pages) │ │ Mobile (Capacitor) │
│ ALL locales bundled │ │ USER LOCALE bundled │
│ PWA SW caches │ │ Other locales lazy │
└────────────────────────┘ └────────────────────────┘
│ │
└────────────────────┬────────────────────────────────────┘
▼
┌──────────────────────┐
│ Browser runtime │
│ EpisodePlayer │
│ Overlay component │
└──────────────────────┘Source of truth: human-edited markdown scripts (with SSML markup) under content/episodes/en-US/. Two pipelines (translation, TTS) produce all derived assets. Both pipelines run identically locally and in GH Actions. The runtime never sees a TTS API key.
2 · Episode taxonomy (rebuilt for 12 routes)
2.1 · Three voice personas
| Persona | Role | Register | Length sweet spot | Routes |
|---|---|---|---|---|
| Curator | Institutional, Sagan-register, "we / humanity / on this small blue dot". Frames why Orrery exists; opens and closes the Full Tour, anchors deep-time pieces on /moon + /mars. | Slow, weighty, declarative. Mid-low pitch. Long pauses earned, not decorated. | 60–120 s segments | / + Full Tour orchestration + deep-time anchors on /moon (moon-one-lifetime) and /mars (mars-what-for) |
| Guide | Personal docent — "look here, see this, watch what happens." First-person engaged. Models the visitor's own attention. | Conversational, warm, builds to clarity. Mid pitch. Punctuation breathes. | 5–8 min screen episodes | All 12 routes (one per route) |
| Enthusiast | Technical-emotional. Equations as instruments. "The signal takes 14.5 seconds. Think about that." Numbers earn emotion through precision. | Brisk, specific, curious. Mid-high pitch. Numbers spoken with their unit. | 90 s – 3 min object episodes | Object-level: each planet on /explore, each mission on /missions, each landing site on /earth//moon//mars, each module on /iss//tiangong, each chapter on /science, each spacecraft on /fleet, porkchop plot on /plan |
The personas are internal editorial tools, not user-facing labels (per PRD-016 §will-not-have). The user just hears the right voice for the moment.
2.2 · Episode count per route (full hierarchy target)
| Route | Guide (screen) | Enthusiast (object) | Total |
|---|---|---|---|
/ | 1 | 0 | 1 |
/explore | 1 | 8 (planets) | 9 |
/missions | 1 | 6 (marquee missions) | 7 |
/fly | 1 | 1 (cislunar) | 2 |
/plan | 1 | 1 (porkchop) | 2 |
/earth | 1 | 4 (landing sites) | 5 |
/moon | 1 | 6 (Apollo + Chang'e + Luna sites) | 7 |
/mars | 1 | 5 (rover sites) | 6 |
/iss | 1 | 4 (modules) | 5 |
/tiangong | 1 | 3 (modules) | 4 |
/science | 1 | 5 (chapters) | 6 |
/fleet | 1 | 4 (spacecraft families) | 5 |
| Subtotal Guide+Enthusiast | 12 | 47 | 59 |
Curator Full Tour segments + Curator deep-time anchors (moon-one-lifetime, mars-what-for) | — | — | 10 (intro, transitions, close, 2 deep-time) |
| Total per locale | 69 episodes at full-hierarchy target |
× 12 locales = 828 audio assets at full hierarchy. v0.7 ships en-US only: 33 logical episodes × 2 providers (Google Neural2 + ElevenLabs A/B) = 66 audio entries × 3 files (.mp3 + .vtt + .txt) ≈ 198 files, ≈ 97 MB on disk. The 12-locale Curator Tour (S7) moves to v0.8 with the translation pipeline (S2).
2.3 · The eight Atmospheric Moves
Editorial anchor — these are the moments the audio system exists for. Every persona / route / locale must land them with weight:
- Signal delay. "When Curiosity radios home, you wait 14 minutes for the answer. Not because the radio is slow — because the universe is large." (Enthusiast,
/marsCuriosity object) - Porkchop plot. The C-shape isn't decorative. Reading the contour is reading a year-by-year argument with the solar system. (Enthusiast,
/planporkchop object) - Pale blue dot. The Voyager image, the Sagan reading, restated for an Orrery user looking at the same scene. (Curator,
/opening tour segment) - 14.5-second one-way. Light-time. Why "real-time" control of a Mars rover is a category error. (Enthusiast,
/marsCuriosity) - Capability ladder close. Apollo 11 to Artemis to Mars — what the missions list tells you about humans, not just spacecraft. (Curator, Full Tour close)
- Cernan's last words. Apollo 17, the last footstep on the Moon, fifty-two years and counting. (Guide,
/moonscreen episode) - Far side. No human has seen the far side directly. The probes that have. (Guide,
/moon) - Curiosity persistence. A robot driving slower than a baby crawls, alone on a planet, every day, for over a decade. (Enthusiast,
/marsCuriosity)
These moves get authored first in en-US, reviewed before any other content, and used as voice-quality reference takes when the per-locale voice ID is being curated.
3 · TTS provider abstraction
3.1 · The interface
// scripts/audio/tts/provider.ts
export interface TtsProvider {
readonly name: 'elevenlabs' | 'openai' | 'google' | 'azure' | 'coqui-local';
generate(input: {
ssml: string; // SSML or plain text per provider capability
voiceId: string; // looked up from voices.json by (provider, locale, persona)
locale: string; // BCP-47, e.g. 'en-US'
persona: 'curator' | 'guide' | 'enthusiast';
}): Promise<{
audio: Buffer; // raw mp3 bytes
captions: string; // WebVTT
transcript: string; // plain text
chars: number; // for cost ledger
cost_usd: number; // computed by the provider impl
}>;
}Each provider implementation lives in scripts/audio/tts/{provider}.ts. The pipeline (§5) imports TtsProvider and selects the implementation by process.env.TTS_PROVIDER. Default elevenlabs.
3.2 · voices.json shape
ℹ️ Illustrative IDs only — the production
static/data/audio/voices.jsonis the source of truth for the shipping voice IDs. The strings below show the shape of the file; concrete IDs (and provider mix) evolve with editorial decisions.
// static/data/audio/voices.json — committed, human-curated (illustrative shape)
{
"elevenlabs": {
"en-US": {
"curator": { "voiceId": "<elevenlabs-curator-voice-id>", "model": "eleven_multilingual_v2" },
"guide": { "voiceId": "<elevenlabs-guide-voice-id>", "model": "eleven_multilingual_v2" },
"enthusiast": { "voiceId": "<elevenlabs-enthusiast-voice-id>", "model": "eleven_multilingual_v2" }
},
"es-ES": { /* ... */ },
"de-DE": { /* ... */ }
},
"openai": {
"en-US": {
"curator": { "voice": "onyx", "model": "tts-1-hd" },
"guide": { "voice": "nova", "model": "tts-1-hd" },
"enthusiast": { "voice": "shimmer", "model": "tts-1-hd" }
}
},
"google": { /* ... */ },
"azure": { /* ... */ },
"coqui-local": { /* speaker reference WAVs paths */ }
}Adding a provider: implement the interface, add the provider's voice IDs under its key in voices.json, set TTS_PROVIDER=newprovider, run the pipeline. Switching providers mid-corpus: the hash-keyed cache (§5) keys on (provider, voiceId, ssml) so a provider swap re-generates only audio that uses voices from the new provider.
3.3 · What this earns us
- Cost optionality. If ElevenLabs's billing surprises us, we re-run the priority cut on OpenAI ($21 vs $300+) without touching pipeline code.
- Locale optionality. ElevenLabs covers ~32 locales; if a future locale isn't supported, we fall back to Google Cloud TTS for that one locale only (mixed-provider corpus is fine — voices.json supports it).
- Failure mode. If ElevenLabs has an outage during a pipeline run, retry with Google or Azure for the affected episodes. Pipeline restart is idempotent.
- Self-hosted future. Coqui XTTS-v2 (local) is in the matrix; if v1.x privacy requirements push toward "no SaaS for narration," the Coqui provider is the destination.
4 · Cost analysis & provider selection
4.1 · Char-count assumptions
- Average screen episode (Guide): 5–8 min ≈ 8000 chars (incl. SSML markup)
- Average object episode (Enthusiast): 90 s – 3 min ≈ 3000 chars
- Average Curator segment: 60–120 s ≈ 2500 chars
Full hierarchy per locale: 11 Guide + 47 Enthusiast + 8 Curator = 11 × 8000 + 47 × 3000 + 8 × 2500 = 88 000 + 141 000 + 20 000 = 249 000 chars per locale.
Full corpus, 12 locales: ≈ 3.0 M chars.
v1 priority cut (English full + Curator × 12): 249 000 (en) + 8 × 2500 × 11 other locales = 249 000 + 220 000 = 469 000 chars total.
4.2 · Provider matrix
| Provider | Pricing | Free tier | Locale coverage | Voice quality | v1 cost | Full corpus cost | Strategic note |
|---|---|---|---|---|---|---|---|
| ElevenLabs | $0.30 / 1k chars on Pro tier ($99/mo, 500k chars included) | Starter: 10k chars/mo (no commercial use) | ~32 locales incl. all 12 of ours | Best in class for prosody, emotion. Voice cloning available. | ~$140 (1 Pro month covers v1 cut comfortably; ~half spent) | ~$900 (or ~3 Pro months at $99 = $297 if amortised) | Anchor for v1. The "museum" target benchmark. |
| OpenAI TTS | $15 / 1M chars (tts-1); $30/1M (tts-1-hd) | $5 trial credit (one-time) | ~50 locales | Decent, less expressive. 6 built-in voices. No cloning. | ~$7 (tts-1) / $14 (tts-1-hd) | ~$45 (tts-1) / $90 (tts-1-hd) | Cheapest at scale. Escape hatch if ElevenLabs costs spike. |
| Google Cloud TTS | $4/M (Standard), $16/M (WaveNet/Neural2) | 1M chars/month free for WaveNet/Neural2 for 12 months; 4M/month free Standard | 50+ locales | Strong (Studio voices excellent for narration). | $0 if spread across 1 month (469k < 1M free) | ~$32 raw or ~$0 if spread over 3 months (1M free × 3) | Best free-tier for v1 if we accept Google's voices. |
| Azure Neural TTS | $16/M (Neural) | 0.5M chars/month free for first 12 months | 60+ locales | Comparable to Google Neural2. | ~$0 (469k < 0.5M free) | ~$32 raw or ~$8 if spread over 6 months (0.5M free × 6) | Strong all-rounder. Good for the long tail of locales. |
| Coqui XTTS-v2 (local) | $0 marginal | unlimited (local compute) | ~16 native, more via voice cloning | Mid-tier raw, excellent for cloning a signature voice. | $0 (M-series Mac runs it) | $0 | Future signature-voice path. Slower iteration; weaker prosody than ElevenLabs today. |
4.3 · Recommended provider sequencing (optionality-preserving)
- v1 (priority cut, ~469k chars): start on Google Cloud TTS Neural2 free tier (~$0). Bumps a knob that costs nothing while we validate the rest of the pipeline (asset hosting, player UX, captions). Editorial quality is a good 80% of ElevenLabs.
- v1.0 ship — switch to ElevenLabs for the en-US Atmospheric Moves only. Re-generate the 8 anchor episodes that carry the museum-grade tone. ~50k chars at ElevenLabs ≈ $15. The other 88% of the corpus stays on Google's free tier.
- v1.x as cost permits — graduate more episodes to ElevenLabs by editorial priority. Voice ID registry is per-provider; mixed corpus is the design.
- v2 / signature voice — Coqui XTTS-v2 + ElevenLabs voice cloning if Marko wants to clone a specific voice as the project's "house voice."
This sequence keeps v1 cost effectively zero, ElevenLabs spend bounded to where it editorially matters, and the door open to swap entirely if any provider's pricing or terms change.
4.4 · Cost ledger
static/data/audio/cost-ledger.json records every TTS call:
{
"version": 1,
"entries": [
{
"ts": "2026-05-20T14:32:11Z",
"provider": "elevenlabs",
"locale": "en-US",
"persona": "curator",
"episode_id": "tour-open",
"chars": 2412,
"cost_usd": 0.7236,
"voice_id": "EXAVITQu4vr4xnSDxMaL"
}
],
"monthly_totals": { "2026-05": { "elevenlabs": 12.45, "google": 0 } }
}Pipeline appends; CI guards thresholds — $50/mo soft warn, $200/mo hard halt (resolved decision §10.8, implemented in scripts/audio/cost-ledger.ts THRESHOLDS).
5 · Async generation pipeline
5.1 · Two pipelines, never mixed
Pipeline 1 — Translation. Reads content/episodes/en-US/{id}.md, calls Claude API to translate to each target locale preserving SSML markup, writes content/episodes/{locale}/{id}.md. Validated for SSML integrity, equation/number presence, ±20 % length tolerance. Translation is the slow + expensive step (Claude API costs); the cache-key is the SHA-256 of the en-US source.
Pipeline 2 — Audio generation. Reads content/episodes/{locale}/{id}.md, looks up (provider, locale, persona) → voiceId in voices.json, calls TtsProvider.generate(), writes static/audio/{locale}/{persona}/{id}.{hash8}.mp3 + .vtt + .txt. Cache-key is SHA-256(provider + voiceId + ssml); identical input never re-generates.
5.2 · Local vs GH Actions — same script, different triggers
# Local — Marko iterates on a script
npm run audio:translate -- --episode tour-open # Pipeline 1, one episode, all locales
npm run audio:generate -- --episode tour-open --locales en-US # Pipeline 2, one episode, one locale (fast)
npm run audio:generate -- --episode tour-open # all locales
# Local — full rebuild
npm run audio:build # both pipelines, full corpus, hash-cache hits skip
# CI — GitHub Actions on script-PR merge
# .github/workflows/audio.yml runs the same scripts; provider creds in repo secrets;
# diffed scripts only re-trigger affected episodes via the same hash-cache.Cost split:
- Local: Marko's machine, Claude API for translation (paid), TTS provider per voices.json (paid or free per §4).
- GH Actions: 2000 free minutes/month on free tier; a full audio rebuild fits in ~30 min if cache is warm. Translation API + TTS API costs the same as local.
When to use which:
- Editing one script + iterating: local, generate one locale, listen, iterate.
- Adding a locale or graduating to a new provider: GH Actions, automated, one PR.
- Translating one new episode across all 12 locales: either; local is faster start-to-finish.
5.3 · Re-translation triggers (PRD-016 OQ10)
When content/episodes/en-US/{id}.md changes:
- Default behaviour: the Claude-based translator re-translates the entire episode's content for all 12 target locales. Cost ≈ 8000 chars × 12 = 96k chars Claude tokens per episode revision.
- Optional optimisation (v1.1): paragraph-level diff. Only paragraphs that changed get re-translated; unchanged paragraphs are reused from the cache. Implementation cost: paragraph IDs in SSML, paragraph-keyed cache. Defer until episode revision frequency justifies it.
5.4 · Pipeline integration with validate-data
scripts/validate-data.ts (the fail-closed gate) gains two new checks at v0.7 audio ship:
- For every script in
content/episodes/en-US/, the matchingstatic/audio/en-US/{persona}/{id}.*.mp3must exist (otherwise fail). - For every shipped audio asset, a row in the
image-provenance.jsonequivalent (audio-provenance.json) must record provider, voice ID, generation timestamp, and char count (per ADR-046/047 spirit, extended to audio).
6 · Audio asset hosting (host-agnostic)
6.1 · Path layout
static/audio/
├── en-US/
│ ├── curator/
│ │ ├── tour-open.a3f1c2d8.mp3
│ │ ├── tour-open.a3f1c2d8.vtt
│ │ └── tour-open.a3f1c2d8.txt
│ ├── guide/
│ │ ├── explore.{hash}.mp3
│ │ └── ...
│ └── enthusiast/
│ ├── explore-saturn.{hash}.mp3
│ └── ...
├── es-ES/
│ └── ...
└── audio-provenance.jsonHash in the filename ({episode-id}.{hash8}.mp3) enables aggressive caching (PRD-016 M13). Filename change on script revision invalidates the SW cache automatically.
6.2 · Web hosting
v1 (current): GH Pages serves static/audio/ alongside the rest of the build. ~97 MB v1 audio + 355 MB existing → ~452 MB repo size, well under GH Pages 1 GB soft limit. Headroom for ~500 MB further audio growth.
v1.0 product milestone (Marko's planned VPS docker-compose migration): hosting moves to the VPS. The audio path layout is unchanged; the build's audio URLs are relative (/audio/{locale}/{persona}/{id}.mp3), so the move is a config change, not a code change. PWA SW cache rules don't change.
Trigger for this hosting decision is product-milestone-driven (Marko's v1.0 plan), NOT audio-corpus-size-driven. We do not need a CDN for this work.
6.3 · Mobile (Capacitor) hosting
Per PRD-015 / RFC-018 §4 (lands v0.8, downstream of audio v0.7): the mobile build will bundle only the user's primary locale of audio. Other locales lazy-fetch from chipi.github.io (or the future VPS) on locale switch and cache via Capacitor's native cache (NOT the PWA SW, which is disabled under Capacitor for v1.0 per RFC-018 §8).
// scripts/build-mobile-audio.ts — runs as part of MOBILE=1 build
// Determines target locale from CAPACITOR_LOCALE env (default: en-US)
// Copies only static/audio/{locale}/* into the Capacitor sync directory
// Other locales remain referenced by URL only; runtime fetches when neededMobile audio bundle add: ~67 MB (full hierarchy in user's locale). Total Capacitor install: ~85 + 67 = ~152 MB, slightly over PRD-018 M11 ceiling (150 MB) — accept the 2 MB overage with a comment, or shave 2 MB elsewhere (likely fleet-thumbnail format change).
7 · Player UX — overlay component
7.1 · Layout
Desktop (≥ 800 px): right-side panel, 360 px wide, slides in from edge. Contains: persona-implicit header (no persona label), waveform visualiser (CSS-only animated bars per RFC-019 OQ — no Web Audio API analysis), now-playing title + duration + scrubber, transport controls (play/pause, skip, speed), caption toggle, transcript download link, episode inventory (collapsible, "for this screen" / "all episodes" tabs).
Mobile (< 800 px): bottom-sheet, 60 % viewport height, drag-handle to expand to 90 %. Same content; transport controls promoted to top of sheet for thumb reach.
7.2 · Trigger
A waveform icon (〜 glyph) in Nav.svelte, between the home link and the locale switcher. Tap opens the overlay; while playing, the icon shows a discrete pulse (PRD-016 S3).
7.3 · Autoplay policy
No autoplay. Browser autoplay restrictions + the museum-grade goal both demand a user gesture. The first-visit-to-screen toast (PRD-016 S6) is a non-modal nudge, not autoplay.
7.4 · Locale-switch behaviour
While an episode is playing, a locale change (URL ?lang= swap or LocaleSwitcher click) restarts the current episode in the new locale at the proportionally-matched timestamp (PRD-016 US-5). Implementation: keep (episodeId, normalisedProgress) in the player; on locale change, look up the new locale's episode metadata, seek to normalisedProgress * newDuration. Best-effort match — translations vary in length.
7.5 · Captions
WebVTT inline. Toggle defaults: ON if ANY of —
window.matchMedia('(prefers-reduced-motion: reduce)').matches, OR- screen-reader detected (
navigator.userAgentheuristics + ARIA live region presence), OR audioElement.muted === trueat episode start, ORnavigator.connection?.effectiveTypeindicates < 1 Mbps (Chromium-only API; non-supporting browsers fall back to the other 3 signals).
User toggle persists for the session only (in-memory; no localStorage).
7.6 · Heard-state
In-memory Set<episodeId>. Lost on reload. Used for:
- Showing a discrete "✓" next to episodes the user has played to ≥ 80 % completion in the inventory list.
- Surfacing "next unheard" in the Full Tour playlist.
- v1 does NOT persist this. Per ADR-057 + PRD-016 M8.
7.7 · Share-link
?audio={episode-id} URL parameter. The +layout.svelte URL-effect (existing canonicalisation pattern) reads it, navigates to the episode's home route if not already there, opens the overlay, autoplays the episode (by URL gesture, which counts as user-initiated for autoplay purposes). Works on web; Capacitor's deep-link handler (orrery://?audio=tour-open, RFC-018 §7) routes through the same code path.
8 · Internationalisation
8.1 · Catalog of supported locales
Inherits from ADR-031/032/033: 12 locales — en-US, en-GB, es-ES, fr-FR, de-DE, it-IT, pt-BR, ja-JP, zh-CN, ko-KR, ru-RU, sr-Cyrl, hi-IN. (List subject to the project's actual localeFromPage registry; verify against src/lib/locale.ts at implementation time.)
8.2 · Phase gating per locale
Per PRD-016 §goal phasing:
- v1: en-US full hierarchy + Curator Full Tour in all 12 locales
- v1.1: Guide-level in en, es, fr, de, it, pt, ja
- v1.2: Enthusiast object-level in en, es, fr, de, it, pt, ja
- v1.3: zh, ko, ru, sr-Cyrl, hi at all levels
A locale is "shipped" when 100 % of its phase-tier audio assets exist + have a curated voice ID + have passed the per-locale voice-quality review (one Atmospheric Moves segment listened to end-to-end by a fluent reviewer).
8.3 · Voice curation per locale per persona
Three voice IDs per locale × 12 locales = 36 voice IDs to curate. ElevenLabs's library is the starting point; if a locale's ElevenLabs voices don't carry the right register, that locale's voice mapping switches to Google Cloud Studio voices in voices.json (mixed-provider corpus, designed-for, §3.3).
8.4 · SSML safety in translation
The Claude-API translator (Pipeline 1) sees the SSML-augmented script as input. It must:
- Preserve every
<break>,<emphasis>,<say-as>tag verbatim (validated post-translation). - Translate the natural-language text inside tags but never the tag attributes.
- Pass through equation placeholders (
<say-as interpret-as="characters">14.5</say-as>) unchanged in source-language form (numbers stay as-is; the TTS provider voices them per locale).
Validation script in Pipeline 1 enforces these by AST-comparison of source vs translated SSML.
9 · Failure modes + handling
| Failure | Detection | Handling |
|---|---|---|
| TTS provider API outage during pipeline run | HTTP 5xx from TtsProvider.generate() | Retry with exponential backoff (3 attempts); on final failure, log + skip + mark in cost ledger as failed; pipeline continues for other episodes |
| TTS provider rate-limit hit | HTTP 429 | Sleep per Retry-After header, resume; if no header, 60 s sleep |
| Free-tier quota exhausted | Provider returns specific error code | Halt pipeline for that provider; ledger flags the threshold breach; operator decides (switch provider for the rest, pay, or ship without remaining episodes) |
| Translation diverges in length > 20 % from source | Pipeline 1 validation step | Fail the translation; flag for manual review (script may have ambiguous phrasing) |
| Claude API translation produces invalid SSML | AST validation in Pipeline 1 | Auto-retry with stricter prompt; second failure → manual review |
Audio asset missing at validate-data time | Existence check in §5.4 | Fail-closed: validate-data exits non-zero; commit blocks |
| User on mobile switches to a non-bundled locale, no network | fetch() reject in audio-load path | Player shows "audio not available offline in this language"; falls back to caption-only playback if VTT was bundled (it isn't by default — open question) |
10 · Resolved decisions + remaining follow-ups
All 12 v1 architectural questions resolved 2026-05-16:
- TTS provider — RESOLVED: ElevenLabs anchor +
TtsProviderabstraction so swap to OpenAI / Google / Azure / Coqui-local is an env var. §3 + §4.3. - v1 provider sequencing — RESOLVED: Hybrid. Google Cloud TTS (free tier) for the bulk of the corpus; ElevenLabs for the 8 Atmospheric Moves anchor episodes only. v1 cost ≈ $15 total. Mixed-provider via
voices.jsonis the design. - Audio hosting — RESOLVED: GH Pages now (
static/audio/). Migration to VPS docker-compose at v1.0 product milestone; hosting layer is host-agnostic so the move is config-only. - Mobile audio — RESOLVED: Bundle user's locale of audio + VTT captions only (~68 MB add). Other locales lazy-fetch.
- Async generation — RESOLVED: Same script, same cache, runs locally + GH Actions. Local for iteration; CI for completeness.
- localStorage for heard-state — RESOLVED: NO. In-memory only per ADR-057. Revisit single-cookie bitset in v1.x if data justifies.
- VTT bundling on mobile — RESOLVED: YES. Captions bundled alongside the user's locale of audio (~1 MB add). Accessibility parity with web; covers Audio.muted + airplane-mode users too.
- Cost-ledger thresholds — RESOLVED: $50/mo soft warn, $200/mo hard halt. Looser than initial $25/$100 recommendation; gives headroom for one-shot rebuilds during iteration.
- Curator Full Tour ordering — RESOLVED: Documentary order (not nav order). Curator opens (pale-blue-dot register) → Solar System big picture → closer to home (Earth, Moon) → missions sent → people in space (ISS, Tiangong) → Mars + future → Curator close.
- Per-locale voice review — RESOLVED: Defer non-en review until v1.1. v1 ships audio in all 12 locales; non-en locales carry a "beta" UI flag in the overlay header. Reviewers recruited in v1.1.
- Voice persona surfacing in UI — RESOLVED: Implicit. No badge, no Curator/Guide/Enthusiast label. User just hears the right voice for the moment.
- Re-translation strategy — RESOLVED: Full episode re-translate on source change (§5.3). Cost ≈ $0.50 / revision. Paragraph-diff optimisation deferred to v1.1.
- Caption auto-on triggers — RESOLVED: ALL FOUR signals —
prefers-reduced-motion, screen-reader detected,Audio.muted == true, ANDnavigator.connection.effectiveTypeindicating < 1 Mbps. §7.5.
Remaining follow-ups (operational, not architectural):
- "Beta" UI flag for non-en locales. Visual treatment + tooltip copy; polish at implementation. Suggested copy: "Voice quality reviewed in en-US only; other locales pending v1.1 review."
- Music bed (v2 candidate). Not in v1. Re-open as a separate PRD if v1 ship surfaces "the silence between segments feels empty."
- Slow-connection caption-on detection —
navigator.connectionbrowser support. Limited (Chromium-only as of 2026). Accept best-effort behaviour; non-supporting browsers fall back to the other 3 signals only.
11 · Tour iteration v2 (v0.7 — implements PRD-016 §S7..S10 + ADR-075)
The v0.7 ship landed the Curator Full Tour as a working museum-grade walkthrough but stopped short of three editorial UX needs that emerged after the test ride:
- The tour bar shows position (
3 / 21) but no time anchor — users can't tell how much they've consumed or how much they have left. - The overlay takes meaningful screen real estate during long listen-throughs that benefit from leaving the visual scene unobstructed.
- The tour is in-memory only per PRD-016 M8; closing the tab loses ~66 minutes of progress.
v2 closes all three under one slice. The fourth piece (§11.4) addresses a parallel ask — reading the tour scripts without playing audio — that emerged from the same user thread and shares the same data layer.
11.1 · Tour timer (PRD-016 §S7)
Pure derived state. No new storage, no new contract.
function tourTotalSec(): number {
return audio.tourSequence
.map((id) => registry.episodeById(id)?.durationSec ?? 0)
.reduce((a, b) => a + b, 0);
}
function tourElapsedSec(): number {
const prior = audio.tourSequence
.slice(0, audio.tourIndex)
.map((id) => registry.episodeById(id)?.durationSec ?? 0)
.reduce((a, b) => a + b, 0);
return prior + audio.positionSec;
}
function tourRemainingSec(): number {
return Math.max(0, tourTotalSec() - tourElapsedSec());
}Rendering (in AudioOverlay.svelte tour-bar, immediately right of the existing tourIndex + 1 / tourSequence.length chip):
TOUR · 3 / 21 · 12:03 / 1:06:24 · 54:21 leftmm:ss for sub-hour durations; h:mm:ss for ≥ 1 h. Shared formatter with the existing transport clock. Visible only while tourActive. Hidden in compact mode header but still part of the compact clock (§11.2).
11.2 · Compact tour mode (PRD-016 §S8)
One reactive flag, one component, two visual states.
State:
// src/lib/audio-state.svelte.ts
compact = $state(false);
toggleCompact(): void { this.compact = !this.compact; }UI affordance: A minimize button in the overlay header, immediately left of the close (×). Icon glyph ▭ → __ (CSS — no new SVG asset). aria-label="Minimize tour" when expanded; aria-label="Expand tour" when compact.
Expanded — today's layout, unchanged.
Compact:
- Desktop (
≥ 800 px): positionbottom: 16px; right: 16px;(clear of viewport edges + any FAB),width: 280px; height: 64px;. - Mobile (
< 800 px): positionbottom: 0; left: 0; right: 0;,width: 100vw; height: 56px;, no rounded bottom corners.
Compact contents (single row, left → right):
- Persona-implicit eyebrow dot (small colored dot — visual cue only, no text label per PRD-016 will-not-have #5)
- Episode title — 1-line truncate,
flex: 1 - Tour position chip —
3/21 - Elapsed clock —
12:03(just elapsed, not full math — saves horizontal space) - Play-pause button — 32×32
- Expand button —
↑32×32 - Stop tour —
×32×32
Captions banner (if CC on) — when compact, the captions banner detaches from the overlay shell and floats at the bottom of the viewport above the compact bar. Same component, different DOM mount.
Provider switcher / inventory / footer — all hidden in compact. Expand to access.
Persistence: Compact flag is included in the orrery_tour cookie payload (cmp field) so it survives cross-session resume per §11.3.
11.3 · Cross-session tour resume (PRD-016 §S9 / ADR-075)
Cookie spec, payload, and resolution-on-visit are defined in ADR-075. This section covers the in-code protocol.
Module: src/lib/audio-tour-cookie.ts
const COOKIE_NAME = 'orrery_tour';
const MAX_AGE_SEC = 2592000; // 30 days
const WRITE_THROTTLE_MS = 5000;
interface TourResumeCookie {
ep: string; // episode id
pos: number; // positionSec
idx: number; // tourIndex
cmp: 0 | 1; // compact flag
}
export function readTourCookie(): TourResumeCookie | null { /* parse + schema-validate; return null on any failure */ }
export function writeTourCookieDebounced(state: TourResumeCookie): void { /* trailing-edge throttle, 5 s */ }
export function writeTourCookieImmediate(state: TourResumeCookie): void { /* used on pause / advance / close */ }
export function clearTourCookie(): void { /* `Max-Age=0` */ }Write triggers (in audio-state.svelte.ts):
| Event | Writer |
|---|---|
positionSec change while tourActive | debounced (5 s) |
tourIndex advance | immediate |
pause() | immediate |
toggleCompact() | immediate |
Overlay close while tourActive | immediate |
Clear triggers:
| Event | Action |
|---|---|
stopTour() | clearTourCookie() |
nextTourId() returns null (natural tour end) | clearTourCookie() |
| Read-time schema validation fails | clearTourCookie() + ignore |
Read-time ep not present in registry | clearTourCookie() + ignore |
Resume affordance (AudioOverlay.svelte, mount-time effect):
- On overlay open (or on first nav mount if overlay auto-opens), call
readTourCookie(). - If valid +
audio.tourActive === false→ render a one-line banner in the overlay header: "Resume tour —<episode title>atmm:ss?" with[Resume]+[× dismiss]. [Resume]click →audio.startTour(CURATOR_FULL_TOUR); jumptourIndexto cookie'sidx; load episodeep; seek topos; restorecompactfromcmp; emit play.[× dismiss]click →clearTourCookie(); banner disappears; user lands in fresh overlay state.- If a tour is already active when the overlay opens, never show the resume banner (the live tour wins).
Autoplay constraint: browser audio policy requires a user gesture. The [Resume] button click IS that gesture; no autoplay attempt is made on cookie-read alone.
11.4 · Transcripts index page (PRD-016 §S10)
A reading-only surface for the 33-episode corpus. Mounts as /library/episodes — a new SvelteKit route, sibling to today's /library/+page.svelte.
Data source:
static/data/audio/audio-provenance.json— per-episode metadata (id, title, persona, route, durationSec, hash, mp3/vtt/txt paths, provider, voice_id, tts_model, text_authorship).content/episodes/sources.json— new sidecar for editorial citations. Schema mirrorstext-sources.json(Pipeline 5).
Sources sidecar schema (static/data/schemas/episode-sources.schema.json):
{
"episode_id": "pale-blue-dot",
"sources": [
{
"label": "Sagan, Carl — Pale Blue Dot (1994), Random House",
"source_id": "random-house",
"url": "https://archive.org/details/pale-blue-dot...",
"kind": "book-primary",
"language": "en",
"last_verified": "2026-06-01"
},
{
"label": "Voyager 1 — Pale Blue Dot photograph, JPL caption page",
"source_id": "nasa-jpl",
"url": "https://photojournal.jpl.nasa.gov/catalog/PIA00452",
"kind": "agency-primary",
"language": "en",
"last_verified": "2026-06-01"
}
]
}Top-level shape: { "episodes": [{ episode_id, sources: [...] }, ...] }. source_id joins static/data/source-logos.json so the existing publisher logos render for free, exactly as /library and /credits already do.
Sidecar starts empty at this slice. Editorial fill happens incrementally per-episode under a follow-up sub-issue. The page renders gracefully when sources are missing — "Sources pending editorial fill" placeholder per row.
Page layout (src/routes/library/episodes/+page.svelte):
┌────────────────────────────────────────────────────────────────┐
│ Library · Audio episodes │
│ 33 episodes · ~97 minutes total · en-US │
├────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Pale blue dot — Voyager looking back curator · / │ │
│ │ 1:55 · drafted by Claude (Anthropic) │ │
│ │ [Read transcript] │ │
│ │ Sources: [Random House] [NASA JPL] │ │
│ └────────────────────────────────────────────────────────────┘ │
│ … one card per episode, grouped by route, documentary order … │
└────────────────────────────────────────────────────────────────┘Sort/group: Documentary order (matches CURATOR_FULL_TOUR) for the tour episodes; the remaining 12 non-tour episodes appear after, grouped by route.
Read transcript — link to the existing static/audio/en-US/{persona}/{episode-id}.{hash8}.txt file. Opens in a new tab (target="_blank" + rel="noopener noreferrer"). No new asset built; transcripts already ship.
Validate-data hook: Extend scripts/validate-data.ts to check that every episode_id in sources.json exists in audio-provenance.json and every source_id exists in source-logos.json. Missing episode_id fails; missing per-episode sources warn but don't fail (incremental editorial fill).
Long-list perf: Apply the existing CSS content-visibility: auto + contain-intrinsic-size pattern already used by /fleet, /library, /credits (TA.md §rendering "Long-list rendering perf").
i18n: Page chrome translated via Paraglide. Episode titles + transcripts are en-US in v0.7 (sidecar entry per locale once Pipeline 1 translation ships under #156).
11.5 · Out-of-scope for tour-v2
Deferred to their own slices:
- URL-as-state for audio (
?audio=ID&t=120&tour=1). Orthogonal to §11.3; can land as a separate share-link feature. Not blocked by ADR-075. - Persistent heard-state bitset. PRD-016 §will-not-have line still binding; revisit under its own ADR if v0.8 usage data justifies.
- Compact mode for single-episode play (non-tour). §11.2 is tour-scoped; per-episode minimize is a v1.x consideration.
- Editorial fill of
sources.json. 33 episodes worth of source research; tracked as a follow-up sub-issue, not part of this slice's ship gate.
12 · Stage authoring — from text-cues to "proper guided tour" (v0.7 — implements PRD-016 §S11)
12.1 · Where we started
The v0.7 ship landed EPISODE_STAGES with five action types (cue, flash, scroll-to, click, open-tab) and a runtime executor in AudioOverlay.svelte. The action types were chosen exactly so the tour could become a guided demonstration — narration that not only describes a screen but actively walks the listener through it.
Today's corpus uses 100 % cue (83 of 83 stages). Zero flash, zero scroll-to, zero click. Zero data-audio-stage anchors in the route pages. The infrastructure has been ready since Phase 2; the editorial pass was deferred to ship en-US on time.
12.2 · What "proper guided tour" means
When the narrator says "look at the route grid in front of you", the page should:
- Scroll the route grid into view (
scroll-to). - Highlight the grid with the warm-gold pulse so the listener's eye lands on it (
flash). - Optionally open a panel mid-segment to support the next sentence (
click).
Each action is timed to the narration line that asks for it. The visual demonstration runs alongside the audio, not after it. The listener doesn't have to navigate — the tour navigates for them.
12.3 · DOM hook convention (already documented in audio-tour.ts)
Stable DOM hooks live as data-audio-stage="<name>" attributes on the target element. The audio system intentionally avoids CSS classes (which churn for styling) and IDs (which conflict with anchor links).
Naming convention:
- Singular hooks:
data-audio-stage="route-grid",data-audio-stage="hero-orrery" - Per-entity hooks:
data-audio-stage="planet-saturn",data-audio-stage="apollo11-marker",data-audio-stage="saturn-v-card" - Sub-region hooks:
data-audio-stage="vis-viva-section",data-audio-stage="porkchop-c-shape"
Selectors used in EPISODE_STAGES must use the attribute selector: target: '[data-audio-stage="route-grid"]'. Never use class or id selectors — they're brittle.
12.4 · Authoring workflow (per episode)
- Re-read the episode SSML at
content/episodes/en-US/<id>.md. Identify each sentence that describes a screen element. - Map each sentence to (action, target) pairs. A typical sentence becomes 2–3 stages:
cue(the narrator's text overlay — already there)scroll-to(bring the element into view, 1–2 s before the narrator says its name)flash(pulse the element when the narrator names it)
- Add
data-audio-stageattributes to the target elements in the route's+page.svelte(or shared component). - Append the new stages to
EPISODE_STAGES[<episode-id>]inaudio-tour.ts. Sort byat_secascending so the executor reads them in order. - Listen-through test: play the episode in the AudioOverlay, watch the page respond.
12.5 · Timing discipline
scroll-toshould fire 1–2 s before the narrator says the element's name. Smooth-scroll takes ~600 ms; the eye needs another moment to focus.flashshould fire at the moment the narrator names the element. The 1.8 s pulse outlives the spoken word naturally.clickshould fire at a natural beat in the SSML — usually a<break>of ≥ 500 ms. Programmatic clicks mid-sentence read as glitchy.- Maximum density: ~1 action every 2–3 s. Stacking actions feels frenetic and competes with the prose.
12.6 · Phased rollout
Per feedback_visual_anchor_before_ux_commit:
- Phase 0 — pilot (this slice). Pick
pale-blue-dot(the tour-open, on/). Re-author from 2 cues to a mixed sequence withscroll-to+flash. Add thedata-audio-stageanchors tosrc/routes/+page.svelte. Ship + show Marko + collect feedback before any other episode is touched. - Phase 1 — three more high-visibility episodes (follow-up sub-issue).
guide-explore,saturn-rings,guide-moon. These touch the most diverse routes (/explore,/moon) and validate the pattern across canvas-driven scenes. - Phase 2 — corpus fill (rolling). Remaining 17 episodes, parallelizable across editorial sessions. Per-episode PR; not a single big drop.
Each phase requires Marko's listen-through approval before the next phase begins.
12.7 · What stays out
open-tabsemantic. Aliased toclickin the executor; not a distinct UX. Useclickeverywhere for clarity.- Stage authoring for non-tour episodes. Object-level "more about this" episodes (saturn-rings, jupiter-storm, etc.) stay cue-only for v0.7. Re-evaluate after the tour pass lands.
- Per-locale stage authoring. Stages are SHARED across locales (selectors don't translate). When v0.8 ships other locales the
at_sectimings may need re-tuning per locale because translated prose has different durations — that re-tune is part of the locale-rollout work (#156), not this slice.
13 · Tour interactivity — structural pass (v0.7 post-ship — #342)
13.1 · Where the corpus landed
Phase 0–15 of #342 took the v0.7 ship state ("100 % cue, zero data-audio-stage anchors") to a fully wired tour: every episode in CURATOR_FULL_TOUR (21) and CURATOR_EXTENDED_TOUR (31) has a per-stage walk-through. The pilot work documented in §12.6 ("pale-blue-dot first, three more, then corpus fill") happened — but compressed into a single 15-phase arc rather than the staged-release plan §12.6 anticipated.
13.2 · Action vocabulary (now seven)
§12 documented five actions. §13 adds two:
drag— dispatchesCustomEvent('audio-stage-drag')on the target element withparams.rotateRad+params.durationMsindetail. Canvas-driven routes (/explore,/moon,/mars,/earth) wire listeners that translate the event into camera motion. The executor stays camera-agnostic.zoom— same pattern withparams.factor(e.g. 0.55 = closer) +params.durationMs.navigate— SvelteKitgoto(target).targetis a URL path / query, not a CSS selector. Used to demo URL-bound state on/missions?q=apollo,/fleet?category=crewed-spacecraft,/science/orbits/vis-viva. Added in Phase 0; lets the audio executor exercise the same URL state the user can.
Total: flash, scroll-to, click, open-tab, cue, drag, zoom, navigate.
13.3 · DOM hook conventions (templated)
§12.3's per-entity hook convention scaled badly — adding 50 mission cards would have meant 50 hidden tour-anchor buttons. Phase 2–5 templated six prefixes onto loops:
route-card-{slug}(root/)science-tab-{tabId}+science-section-{sectionId}(/science)missions-select-{missionId}(/missions)fleet-select-{entryId}(/fleet)mars-select-{audioId}(/mars— templated over a constantTOUR_ANCHORSlist because the surface scene doesn't expose every marker as a DOM button; the list maps audioId → siteId, sincepathfinder≠mars-pathfinder).explore-select-{planetId}(/explore)
The audio-tour.test.ts invariant gained a TEMPLATED_PREFIXES allowlist (src/lib/audio-tour.test.ts:302). Any new templated prefix must be added there; otherwise the per-stage selector existence check fails.
Shared components carry their own attrs (component file in the test's SOURCE_FILES):
PanoramaToggleButton.svelte→surface-stand-at-site/surface-exit-panoramaPanoramaAutoTour.svelte→surface-panorama-tour-playStationAssemblyControl.svelte(/issbutton) →iss-assembly-togglePlanetPanel.svelte→planet-tab-{name}
13.4 · Panorama + assembly playback integration
Three surface episodes now physically take the listener to the site as the narrative builds to it:
- cernan-last-words (
/moon, Apollo 17 Taurus-Littrow) — descent at t=38, auto-tour @ t=65 panning through South Massif → Camelot Crater rim → LRV, exit at t=100 before the "closed chapter" close. - curiosity-persistence (
/mars, Curiosity Gale) — descent at t=50 on the "Alone." beat, auto-tour @ t=68 through Gediz Vallis → Robotic arm → Mt. Sharp, exit at t=98. - guide-moon (
/moon, Apollo 11 Tranquillity Base) — brief 10 s descent @ t=90 during the "Apollo cluster cheapest trajectories" beat. No auto-tour (window too short).
ISS assembly playback (StationAssemblyControl, 50 s fixed) is wired into zarya-first-module (Phase 1): click iss-assembly-toggle at t=80, close at t=125 to give /tiangong a clean ISS module-list state at the auto-advance.
Panorama / assembly handoff rule (§13.6): the audio-tour's exit-panorama / assembly-close click force-stops any in-progress auto-tour or playback as a side effect — AutoTour's active=false effect runs clearDwell() and sets playing=false. Subsequent stages and the next episode are never blocked.
13.5 · Tour-resume race condition (Phase 10)
ADR-075's orrery_tour cookie restores {ep, pos, idx, cmp} on page load. When the restored position lands inside an episode's mid-section (e.g. t=70 inside cernan's panorama window 38 – 100), the stage-firing $effect batches every stage whose at_sec ≤ pos into a single tick. The Phase 0–9 synchronous loop fired them all in one JS turn, so a state-establishing click (e.g. moon-select-apollo17 at t=18) did not get a tick to mount its panel before the next click (surface-stand-at-site at t=38) looked for the stand-at-site button the panel mounts. The downstream click silently no-op'd, leaving the listener on the map view at audio-t=70 instead of in the panorama.
Phase 10 fix (AudioOverlay.svelte:212):
- Collect the catch-up batch.
- Mark every key fired up-front so a re-tick can't race the in-flight sequence.
- Run the batch in an async loop with
await tick()after each action.
State-establishing DOM mutations now flush before the next click's selector lookup. The same fix covers user-scrub-forward.
13.6 · Authoring rules (canonical)
Per AGENTS.md § "Tour cue authoring":
- Time against the VTT, not the SSML target. ElevenLabs renders 30 – 45 % faster than guide-tier SSML targets. Anchor every
at_secagainst a specific VTT line; reference it inline (// VTT § 00:00:48.9 "Click Curiosity"). Phase 8 + Phase 11 caught 34 stale timings from before this rule was applied. - The screen does what the voice says. Every imperative (
click X,drag,look at) gets a stage within ±3 s. Every named entity with a route hook gets a flash or click when it's spoken. - 1 s cue→click default. Cue banner shows ~1 s before the click; banner visibility (default 6000 ms) persists through and after. Hold 2 s only for cues > 8 words or when
drag/zoomfollows. - Cue text is directive, not subtitle. "Click any planet — like this." beats "We now click on a planet."
- No overlap. Don't replace a cue banner mid-read, don't stomp a flash within 1.8 s on a different target, don't yank a scroll within ~2 s, don't fire a click while a panel from a previous click is still animating.
- Roll-calls get per-item hooks. When narration names a sequence ("Vostok → Voskhod → Mercury → Gemini → CSM → Soyuz"), add per-card hooks and flash them in order. Single container-flash is wrong for roll-calls.
navigatefor URL-bound state. Search / filter affordances on routes whose state lives in the URL (/missions,/fleet).- Verify in browser. A failing selector silently no-ops; preflight doesn't catch it.
13.7 · Stage-authoring tool (Phase 13)
scripts/audio/suggest-stages.ts reads an episode's SSML frontmatter + ElevenLabs VTT and emits a TS object-literal block ready to paste into EPISODE_STAGES. Pattern matchers cover click <Entity>, drag/rotate, look at/find/notice, zoom in/out. Selector resolution is heuristic (slugifies the noun after the imperative); cue text comes back verbatim and is explicitly marked REWRITE. Author refines, then validates against audio-tour.test.ts.
Use it for new v0.8 episodes instead of walking each SSML by hand.
13.8 · Analytics expansion (Phase 14)
src/lib/analytics.ts event schema expanded from the v0.7 7-item suggestion list to a documented contract covering tour, nav-flow, item-click, gallery, science, /fly, panels, and locale events. Typed helpers (trackStageFire, trackRouteEnter, trackItemClick, trackGalleryImageLoad, etc.) prevent prop-name drift.
Wired emit sites in Phase 14:
audio-stage-fire{ episode, action, at_sec, target_prefix } — insideAudioOverlay's async stage-execution loop.route-enter/route-exit{ route, from_route, dwell_ms } — viaafterNavigatein root layout.
Other emit sites (item-click per route, gallery-image-load, science-section-view, lens/layer toggles, cmdk-search, mission-load on /fly, panel-tab-open) are mechanical follow-ups — schema + helpers are in place.
13.9 · What § 13 supersedes from § 12
- §12.3's per-entity-hook example (
apollo11-marker,vis-viva-section) → use templated prefixes from §13.3 instead. - §12.6's phased-rollout ("pilot, then three, then corpus fill") → completed under #342 Phase 0 – 15.
- §12.7's "stage authoring for non-tour episodes stays out" → reversed in #342; all 31 staged episodes now have rich stages.
§12.4 (per-episode workflow) and §12.5 (timing discipline) are unchanged; both still describe the canonical authoring loop, though the discipline numbers in §12.5 are formalised in §13.6.
RFC-019 · Orrery · Science Overlay & Episode System · Drafted 2026-05-16 · §11 Tour v2 added 2026-06-03 · §13 Tour interactivity structural pass added 2026-06-15 (#342) · Closes-into-PRD-016