Oannes Engine — Per-Post Variety (rotate voices + narration structures)
Date: 2026-06-27
Status: Approved (design)
Roadmap item: #2 from the 2026-06-27 deep-research strategy
Related: docs/research/2026-06-27-faceless-brand-growth-research.md, docs/superpowers/specs/2026-06-16-oannes-learning-loop-design.md
Problem
The deep-research strategy (2026-06-27) confirmed that YouTube’s July-2025 “inauthentic content” policy targets templated / reused-narration content. The Oannes pipeline’s signature — one ElevenLabs voice (Ash), one narration template, twice a day — is exactly that pattern. Per-post variety is roadmap item #2 (after DM-shareable hooks, which shipped in 7ce52ee).
Account-type gate (roadmap #3) was verified the same session: TikTok @oannes_code is a Personal account and Instagram @oannescode is a Creator account — neither is Business, so the trending-audio lockout does not apply. (Informational; not part of this build.)
Goal
Rotate narrator voice and narration structure per post so no two consecutive drops share the same voice or structure, and wire both into the existing learning loop as new dimensions (alongside vibe / artKey / format) so the engine can eventually learn which voice/structure performs best per platform.
Decisions locked with Joshua:
- Voice pool: on-brand male narrator voices (channel stays coherent; not a wide gender/accent mix).
- Learnable now: full learnable dimensions, not just rotation. Voice + structure feed the money-weighted epsilon-greedy bias like vibe/artKey.
Non-goals (YAGNI / v1 scope boundaries)
- Carousel framing stays the fixed 6-slide layout. Structure rotation applies to the reel narration only (the YouTube-flagged artifact). Carousels go to Instagram only and have no VO.
- No new env config. The voice pool and structure list are code constants. Default voice remains Ash, included in the pool.
- No change to the no-signal gating contract: with null or zero-sample weights, behavior is pure no-consecutive-repeat rotation (today’s safe default).
Design
1. Voice pool — src/media/voices.ts (new)
On-brand male narrator voices, all narrator-grade for awe/mystery:
| key | voice | ElevenLabs id |
|---|---|---|
ash |
Ash — calm, soothing, magnetic (current default) | VU16byTywsWv5JpI8rbc |
brian |
Brian — deep, resonant, comforting | nPczCjzI2devNBz1zQrb |
george |
George — warm, captivating storyteller (British) | JBFqnCBsd6RMkjVDRZzb |
bill |
Bill — wise, mature, gravitas | pqHfZKP75CvOlQylNhV4 |
Exports VOICE_POOL: { key: string; voiceId: string; label: string }[] and helpers to look up a voiceId by key. Voice applies to reels only.
2. Structure archetypes — src/script/structures.ts (new)
Each archetype is a short directive injected into buildReelPrompt. All preserve the existing HARD RULES (attribution, no fabrication, no criminal accusations) and the forwardable-hook goal; they govern the shape of the narration body, not the rules.
| key | directive (summary) |
|---|---|
cold_open |
Drop straight into the strangest image/detail, then reveal who said it and what it means. |
question_spiral |
Open on a provocative question, escalate through 2-3 deeper questions, land on the claim as the unsettling answer. |
witness |
Second-person immersion (“Picture this…”, “Imagine you’re…”) placing the viewer in the scene, then attribute. |
contrarian |
State the consensus / what everyone assumes, then pivot hard to the claimant’s challenge. |
escalation |
Start small and plausible, ratchet through increasingly wild implications to a mind-bending payoff. |
confession |
Frame as a startling admission/revelation the claimant made that most experts won’t say out loud. |
Exports STRUCTURES: { key: string; directive: string }[] and a structureDirective(key) helper.
3. Rotation + learning — src/auto/rotate.ts, src/metrics/apply.ts
Mirrors the existing artKey path exactly.
RotationStategainsrecentVoices: string[]andrecentStructures: string[](most-recent-first, capped at 3, no-consecutive-repeat). Back-compat:readStatedefaults them to[].planTodayLearned(state, weights, rng, epsilon)now returns{ vibe, art, voiceKey, voiceId, structure }.- Voice and structure candidates are global pools (not vibe-dependent).
- Selection reuses the existing fresh-candidate epsilon-greedy picker (
biasArtKey, generalized/renamed to a genericpickFreshKeywithbiasArtKeyretained as an alias to avoid churn) over candidates not in the recent list, scored byblendedDimensionScores(weights, "voice"|"structure", K). - Null/zero-sample weights →
planToday-equivalent: alternate vibe, fresh-but-unscored (random among non-recent) voice/structure. advance(state, vibe, artKey, voiceKey, structure)updates all four recency lists.blendedDimensionScoresdim union extends to"vibe" | "artKey" | "topic" | "voice" | "structure".
4. Script + generation — src/script/reelscript.ts, src/auto/daily.ts
buildReelPrompt(brief, vibe, guidance?, structure?)injects the structure directive as aNARRATION STRUCTURE for this video:block when provided.writeReelScriptgains thestructureparam (optional, threaded through).daily.ts:- Take
{ vibe, art, voiceKey, voiceId, structure }fromplanTodayLearned. - Pass
structureintowriteReelScript. - Use the chosen
voiceIdinsynthesizeVoiceoverTimed(replacing the hardcodedprocess.env.ELEVENLABS_VOICE_ID; env stays the Ash default the pool references). - Record
voiceKey+structureon thePendingItem. advance(...)with the new args.
5. Metrics plumbing — src/metrics/store.ts, src/metrics/collect.ts, src/metrics/analyze.ts
Applies the lesson from the format-column bug (the format index in CREATE_TABLE_SQL ran before its ALTER, silently killing collection 06-20→06-24):
store.ts:- Add
voice textandstructure textcolumns toCREATE_TABLE_SQL(for fresh installs) with no index there. MIGRATE_SQLgainsALTER TABLE … ADD COLUMN IF NOT EXISTS voice text;/… structure text;and theCREATE INDEX IF NOT EXISTSfor any voice/structure index — indexes live only inMIGRATE_SQL, after the column exists.MetricSnapshotgainsvoice: stringandstructure: string.buildInsertadds the two columns ($20,$21);fetchLatestSnapshotsmaps them (defaulting to"").collect.ts: when building each snapshot,- reel result →
voice: item.voice ?? "",structure: item.structure ?? ""; - carousel result →
voice: "",structure: ""(someanByKey‘sif (!k) continueexcludes carousels from voice/structure learning, keeping the signal reel-clean). analyze.ts:PlatformWeightsgainsvoiceandstructuremaps;aggregatecomputes them viameanByKey;PlatformWeightsSchemaaddsvoice/structureasz.record(z.number()).default({})for back-compat with existingweights.json.
6. State — src/auto/state.ts
PendingItem gains optional voice?: string (the voice key) and structure?: string. Optional so older queued items load without them.
7. Discord preview — src/auto/daily.ts
The preview content line gains 🎙️ <voice label> · 🧬 <structure key> for at-a-glance confirmation and debugging.
Data flow (unchanged shape, two new tags)
planTodayLearned → {vibe, art, voiceKey, voiceId, structure}
→ daily.ts: writeReelScript(structure) + TTS(voiceId)
→ PendingItem{voice, structure, …}
→ publish → results
→ collect.ts: MetricSnapshot{voice, structure, …} (carousel → "")
→ store: oannes_post_metrics(voice, structure, …)
→ analyze: PlatformWeights{voice{}, structure{}, …}
→ apply/rotate: blendedDimensionScores("voice"|"structure") → next pick
Testing
New/extended unit tests (tsc-gated, existing vitest suite):
- rotate: no-consecutive-repeat for voice and structure; planTodayLearned returns the new fields; null-weights path is unbiased rotation.
- apply: blendedDimensionScores over "voice"/"structure" blends per-platform/confidence correctly.
- store: buildInsert emits the right column count/order including voice/structure.
- analyze: aggregate computes voice/structure means and skips empty keys; schema parses a legacy weights file (no voice/structure) via defaults.
- reelscript: prompt includes the structure directive when a structure is passed, omits it otherwise.
- collect: snapshot carries voice/structure for reel results and "" for carousel results.
Risks & mitigations
- DB migration repeat of the format bug → indexes only in
MIGRATE_SQL,ADD COLUMN IF NOT EXISTS, verified against the live Cambium table before merge. - Biasing on tiny samples is noisy → epsilon-greedy (0.2) + confidence blend
sampleSize/(sampleSize+8)already damp this; null/zero-sample gating means no bias until data exists. - British voice (George) breaking coherence → accepted; all four are narrator-grade and the variety is the point. Easily removable from
VOICE_POOLif it reads wrong. ```