Study audio/video studio (technical)¶
Design + toolchain research only. Nothing in this document is built yet, except the anti-hallucination verification gate — no ffmpeg/Piper pipeline, no controllers, no generator implementation. This is the plan the follow-up build agents (see Build-agent breakdown) execute against.
Update (Phase 2 integration):
IStudyContentVerifier(agent 5,content-verification-gate) is now implemented, ahead of the rest of this doc's build-agent plan, as an integration-branch seam — seeWorkWingman.Core/Interfaces/IStudyContentVerifier.csandWorkWingman.Infrastructure/Verification/. The registered default isCompositeStudyContentVerifier: a free, instant, no-external-processDeterministicStudyContentVerifier(checks a claim against a small curated table of well-established Big-O complexities and pattern-recognition cue-to-pattern pairings) always runs first, falling through toCodexStudyContentVerifier— a real, workingcodex execCLI shell-out (config-gated viaWorkWingman:StudyContentVerification:UseCodex, off by default) — only when the deterministic check can't resolve the claim (Flagged) and Codex verification is enabled.GeminiStudyContentVerifierremains a documented-but-not-built config-gated upgrade option, same asCodexStudyContentVerifierwas before this pass. The gate's first real consumer isFlashcardService'sTechConceptcards (seeApplyVerifiedComplexityHintsAsync): a JD keyword matching a catalogued algorithm/data-structure gets a verified Big-O hint appended, never an unverified (Flagged) one. Everything else in this document — the ffmpeg/Piper audio/video pipeline itself — is still unbuilt; see Build-agent breakdown.Related: prep-studio.md already ships in-browser, ephemeral flashcard audio via the Web Speech API (
speechSynthesis.speak()— totally free, zero file output, disappears when the tab closes). This feature is deliberately different and complementary: it renders real, portable MP3/MP4 files you can copy to a phone, sync to a car head unit, or play on a TV — durable artifacts, not a live browser voice. Nothing here replaces the flashcard reader.
TL;DR¶
- Goal: turn a job's existing study material (gap analysis, resume-vs-JD notes, flashcards, interview-prep bank) into portable audio/video files: MP3s for phone/car listening and for sleep, MP4s for a TV in the background.
- Free-first, local-first, no API key required for the default path. Every default tool is free software that runs on the user's machine. Paid/cloud upgrades (better TTS voices, AI music APIs) are config-gated, BYO-key, and off by default — same convention as the rest of WorkWingman (see architecture.md for the standing BYO-key pattern used by ATS accounts and learning platforms).
- No medical claims, anywhere. The "bilateral" sleep variant is a relaxation-style alternating left/right stereo pan — explicitly not EMDR, not a treatment. See Guardrails.
- Copyright-clean by construction. Only CC0/royalty-free assets and locally-generated audio are ever bundled or referenced. Every asset ships with its license recorded. No copyrighted music or sound effects are ever embedded.
- Toolchain verdict: ffmpeg (already a de facto standard, confirmed installed locally via winget, LGPL/GPL depending on build) does all mixing, panning, MP3/MP4 encoding. Piper does local TTS narration. CC0 rain/thunderstorm beds come from curated Freesound.org / Pixabay sources (or synthesized with ffmpeg's own noise generator as a zero-download fallback). AI "catchy" music defaults to curated CC0 loops, not MusicGen (see AI music for why).
- Scripts are written for passive, low-attention listening (driving, falling asleep) — recognition-first phrasing, not dense prose. See Content modes for low-attention listening.
- No unverified technical claim ever reaches audio. Any Big-O, algorithm-correctness, or data-structure fact a script asserts is cross-checked by a second model before narration — see Anti-hallucination verification gate, the single most important addition in this pass.
Guardrails (read first)¶
These are non-negotiable constraints on every variant this feature ever produces, present in the design from day one so no build agent can "optimize" them away later.
1. No medical or therapeutic claims¶
- EMDR (Eye Movement Desensitization and Reprocessing) is a licensed clinical trauma therapy administered by trained practitioners. This feature does not implement EMDR, does not claim to, and must never be marketed or labeled as if it does.
- The "bilateral" sleep variant is framed only as a relaxation-style alternating left/right stereo ambience meant to give sleep audio a sense of gentle motion — the same category as a white-noise machine, not a clinical intervention.
- Every surface (UI copy, file metadata, docs) that mentions "bilateral" carries this exact disclaimer, verbatim or materially equivalent:
"This is a relaxation-style alternating left/right stereo ambience for focus or sleep atmosphere. It is NOT EMDR therapy and NOT a medical treatment. It does not diagnose, treat, cure, or prevent any condition. If you have a clinical need (trauma, anxiety, insomnia, etc.), consult a licensed professional."
- No copy anywhere claims this feature improves memory, cures insomnia, reduces anxiety, or produces any other health outcome. It is background audio for studying and for falling asleep, nothing more.
2. Safety — driving and machinery¶
- Every sleep variant (bilateral-mp3, storm-mp3) carries: "Do not play while driving or operating machinery — designed to help you relax and disengage, which is the opposite of what driving requires."
- The car variant is the one variant explicitly designed to be engaging/alert (catchy music-backed narration) — it is the "keep you awake and focused" option, not the "wind down" option. Its own disclaimer is the inverse: this is for active listening, keep attention on the road, treat it like any other audio you'd play while driving.
- These disclaimers are embedded in generated file metadata (ID3 comment tag for MP3, metadata comment for MP4) and shown in the UI at generation and export time — not just buried in docs.
3. Copyright¶
- Only CC0 (public domain dedication) or explicit royalty-free-for-any-use assets are ever bundled with the app or referenced by default config. No commercially copyrighted music, sound effects, or video clips are ever embedded, downloaded automatically, or shipped.
- Every asset (rain bed, thunderstorm bed, background video loop, music loop) has a recorded source URL, license, and author in an asset manifest (see Asset manifest & licensing) — auditable, one row per asset.
- Locally-generated audio (Piper narration, ffmpeg-synthesized noise) has no copyright exposure at all — it's generated fresh from the user's own text and math, not sourced from a third party.
- Paid AI-music APIs (Suno, Udio) are never the default and are flagged with their own ToS caveats if a user opts in (see AI music).
4. Free-first + config-gate¶
- Default pipeline requires no API key, no cloud account, no payment — Piper + ffmpeg + bundled/downloaded CC0 assets, 100% local.
- Any paid/cloud swap-in (cloud TTS, paid AI music) is opt-in via config, follows the same
BYO-key convention as the rest of the app (see
apikeys/feature-apikey-onboardingbranch and architecture.md), and the app remains fully functional using only the free path if no key is configured.
5. Privacy¶
- Generation is entirely local: text-to-speech, mixing, and encoding all happen on the user's machine via subprocess calls to Piper and ffmpeg. No study content (resume, JD, gap analysis, flashcards) is ever sent to a third-party service in the default path.
- The only exception is if the user explicitly opts into a cloud TTS or paid music API — at which point exactly the narration text or music prompt (never the full profile/resume) leaves the machine, same minimal-payload principle the rest of the app follows for BYO-key features.
- Output files (MP3/MP4) are written to the user's own file system (a
study-audio/output folder under the app's data directory), never uploaded anywhere.
6. Don't study a hallucination — technical content must be verified before it ships¶
This is a hard pipeline stage, not an optional nicety. Any script content that asserts a technical fact — Big-O complexity, algorithm correctness, a data-structure trade-off, "this pattern applies when you see X" — is generated by one model (Claude) and must be independently cross-checked by a second model before it is allowed to reach narration, audio, or flashcards. An LLM confidently narrating a wrong complexity claim or a mis-stated algorithm into an MP3 the user listens to passively (half-asleep, driving) is a worse failure mode than the same error in text the user can visually double-check — passive audio consumption is inherently lower-scrutiny, so the content behind it needs more verification, not less. Full design: Anti-hallucination verification gate. This gate is app-wide, not audio-specific — it wraps every place Claude generates technical study content (flashcards, ELI5 explainers, Claude-project material), and study-audio is simply the first consumer designed against it.
Content modes for low-attention listening¶
Driving and falling-asleep are low-attention consumption modes — the listener isn't taking notes, can't rewind easily (driving) or is intentionally drifting off (sleep), and dense information-per-sentence prose is the wrong shape for either. Scripts generated for study-audio use two content modes, chosen per variant, both optimized for passive, recognition-first listening rather than active study:
- ELI5 mode ("explain like I'm 5") — plain-language, analogy-first explanations of the underlying concept before any jargon. E.g. "A hash map is like a coat check — you hand over your coat (the key), get a ticket (the hash), and later use that ticket to get your coat back instantly instead of checking every rack." Used for concepts the user's gap analysis flags as weak, where the goal is building intuition passively.
- Pattern-recognition mode — short, repetitive, cue-to-pattern phrasing meant to stick on repeated listens rather than be understood in one pass:
- "When you see {cue}, it's a {pattern} problem." — e.g. "When you see 'find the longest substring without repeating characters,' that's a sliding-window pattern." / "When two sorted arrays need merging in place, that's two-pointer." / "When a problem asks for the number of ways to reach something, that's dynamic programming." / "When you're exploring a graph level by level, that's BFS."
- "{Algorithm} is O({complexity}) because {reason}." — e.g. "Binary search is O(log n) because each comparison halves the remaining search space."
- Data-structure trade-offs stated as short comparisons: "An array gives O(1) lookup by index but O(n) insertion in the middle; a linked list flips that trade."
- Behavioral/leadership question TYPES, not scripted answers: "When an interviewer asks about a time you disagreed with a teammate, they're really testing conflict handling, not looking for a specific outcome — structure your answer as situation, action, result."
- Deliberately repetitive across a script (the same cue-to-pattern pairing restated a few ways) — repetition is the point for something meant to be absorbed passively, not read once.
Which mode(s) a script uses is a per-resource setting (a flashcard or gap-analysis item can be
tagged ELI5, pattern-recognition, or both); this is a script-generation concern that sits
upstream of INarrationSynthesizer (which only ever receives already-approved plain text) — see
the verification gate
immediately below for why script generation and narration are kept as separate pipeline stages.
Anti-hallucination verification gate ("don't study a hallucination")¶
STATUS: implemented (Phase 2 integration pass) — see the update note at the top of this doc. The interfaces/models below are the real, shipped shapes (not placeholders); the churn loop and resolution rules they describe are enforced by
DeterministicStudyContentVerifier+CodexStudyContentVerifiertoday.
Why this exists¶
Study-audio scripts routinely assert checkable technical facts — "this is O(n log n)", "use a min-heap here", "this is a sliding-window problem" — generated by an LLM (Claude) from the user's gap analysis and prep-bank content. LLMs hallucinate confidently, and the pattern-recognition mode above deliberately compresses claims into short, memorable, repeated assertions — exactly the shape that makes a wrong claim maximally sticky if it slips through. Combined with passive consumption (driving, half-asleep), the cost of an unnoticed wrong claim is higher here than almost anywhere else in the app. This gate exists so no technical claim reaches an audio file, flashcard, or Claude Project artifact without being checked by a model other than the one that wrote it.
The seam¶
namespace WorkWingman.Core.Interfaces;
using WorkWingman.Core.Models;
/// <summary>
/// Cross-checks a technical study claim against a second model and/or curated ground truth
/// before it is allowed to reach any study artifact (audio script, flashcard, Claude Project
/// material). Applies app-wide — study-audio is the first caller, not the only one.
/// </summary>
public interface IStudyContentVerifier
{
/// <summary>Verify one generated claim. Returns Verified, Corrected (with the fixed text),
/// or Flagged (disagreement the models couldn't resolve — never shipped, surfaced to the
/// user as "unverified" instead of silently dropped or silently trusted).</summary>
Task<VerificationResult> VerifyAsync(TechnicalClaim claim, CancellationToken ct = default);
}
public class TechnicalClaim
{
public string ClaimText { get; set; } = string.Empty;
/// <summary>Big-O, algorithm-correctness, data-structure, or pattern-recognition claim.</summary>
public TechnicalClaimKind Kind { get; set; }
/// <summary>Optional grounding: the JD/gap-analysis topic, LeetCode problem slug, etc.</summary>
public string? Context { get; set; }
}
public enum TechnicalClaimKind { BigOComplexity, AlgorithmCorrectness, DataStructureTradeoff, PatternRecognition, Other }
public enum VerificationOutcome { Verified, Corrected, Flagged }
public class VerificationResult
{
public VerificationOutcome Outcome { get; set; }
/// <summary>Only set when Outcome == Corrected — the fixed claim text.</summary>
public string? CorrectedText { get; set; }
/// <summary>Which verifier(s) were consulted and what each concluded, for audit/debugging.</summary>
public IReadOnlyList<string> VerifierNotes { get; set; } = [];
}
The churn loop¶
- Generate. Claude drafts the script content (ELI5 explanation or pattern-recognition phrasing) from the user's gap analysis / prep-bank / flashcard source.
- Extract claims. Any sentence asserting a checkable technical fact is pulled out as a
TechnicalClaim— this is a narrow, mechanical extraction (Big-O statements, "X is a Y pattern" statements, data-structure trade-off statements), not a second full rewrite. - Independent-model verify. Each claim is sent to a different model than the one that generated it:
- Default, free-first: a
CodexStudyContentVerifiershelling thecodex execCLI — already an installed, working transport in this codebase (seeIAdvisorTransportand itscodex execimplementation forCodexPageAdvisorinpageagent-advisor-design.md— the same transport pattern is reused here, not reinvented). - Optional, config-gated: a
GeminiStudyContentVerifier(BYO API key, same convention as every other cloud/paid option in this doc) for a third opinion when configured. - Ground against curated sources, not just model opinion. Verification isn't purely
model-vs-model — each claim is also checked against the curated authoritative catalog already
in the app: the
InterviewPrepBankcontent, the learning-paths resource catalog, and — when one exists for the job — the user's own Claude Project / NotebookLM-style grounded workspace (seeIStudyPlanService.GenerateClaudeStudyProjectAsync). These are the ground truth the verifier model reasons against, not just "does this sound right" model-to-model agreement. - Resolve.
- Agreement (generator and verifier(s) concur, consistent with curated sources) →
Verified, ships as-is. - Verifier catches a clear error →
Corrected, the fixed text ships (with the correction logged for audit — silent overwrite of a wrong claim is fine, silent disagreement is not). - Models disagree and it can't be mechanically resolved →
Flagged. Flagged content never ships to audio/flashcards as if it were fact — it's either dropped from the script or, if kept, explicitly presented as "unverified — confirm independently" rather than stated with the same confidence as verified content. Silence is not a valid resolution here. - Only verified (or corrected) content reaches
INarrationSynthesizer. The narration synthesizer and ffmpeg pipeline never see unverified text — verification is a gate the script passes through before audio generation starts, not a post-hoc check on the finished file.
Scope — this is app-wide, not audio-specific¶
IStudyContentVerifier is declared as a WorkWingman.Core seam precisely so it isn't
audio-only plumbing. The same gate should wrap:
- Flashcard content (FlashcardService / FlashcardDeck — see
prep-studio.md) wherever a card states a technical fact rather than just a
Q&A pairing already sourced verbatim from the JD/resume.
- ELI5/pattern-recognition audio scripts (this document).
- Any future Claude Project material that asserts technical claims rather than merely
summarizing the user's own JD/resume content (summarizing the user's own text doesn't need
fact-verification; asserting an external technical truth does).
Build-agent scoping note: implementing IStudyContentVerifier and its codex exec transport is
its own build agent (see the updated
Build-agent breakdown), not folded into the audio-pipeline agent —
it's a shared, app-wide seam and deserves its own tests independent of study-audio.
Toolchain verdict¶
| Tool | Role | Free/local default? | License | Notes |
|---|---|---|---|---|
| ffmpeg | Mixing, panning, MP3/MP4 encoding, noise/video generation | Yes — confirmed already present on this machine via winget (Gyan.FFmpeg, ffmpeg-N-*-gpl build) |
LGPL 2.1+ (GPL if built --enable-gpl, as the local install is) |
Already a de facto industry standard; the existing WorkWingman dependency doctor pattern fits it directly (see Dependency doctor additions). |
| Piper TTS | Narration text → WAV | Yes | GPL-3.0 (active fork, OHF-Voice). The original rhasspy/piper repo was MIT but is now archived (Oct 2025) and unmaintained — the maintained project moved to OHF-Voice/piper1-gpl under GPL-3.0. We invoke it as an external CLI subprocess (piper --model ... --output_file ...), not by linking its code into WorkWingman's binary, so GPL's copyleft does not extend to WorkWingman's own source — same arrangement as shelling out to ffmpeg/winget/gh today. |
Voices are separate .onnx + .onnx.json downloads, mostly MIT/CC-BY-4.0 (verify per-voice — VOICES.md links each voice's source and license; a few are more restrictive (Blizzard Challenge-derived) and must be excluded from the bundled default voice set). |
| eSpeak-ng | Fallback TTS if Piper unavailable/fails to install | Yes | GPL-3.0 | Piper itself embeds eSpeak-ng for phonemization, so it's already a transitive dependency. Much more robotic-sounding than Piper's neural voices but tiny, fast, and works everywhere Piper's ONNX runtime might not (e.g. very old hardware) — kept as a documented fallback, not part of the default pipeline. |
| Coqui TTS | Considered, rejected as default | N/A | MPL 2.0 (project itself unmaintained since the company shut down in 2024; community forks exist) | Heavier (PyTorch dependency), slower, and no longer actively maintained upstream — Piper is the better-fit default for a Windows desktop app that needs a small, fast, single-binary TTS engine. Not pursued further; noted here so it isn't re-researched later. |
| Cloud TTS (Google Cloud TTS / Azure Speech / ElevenLabs) | Optional higher-fidelity narration voice | No — config-gated, BYO key, off by default | Per-provider commercial ToS | Same convention as the prep-studio flashcard reader's noted future upgrade path. Wired through the existing API-key onboarding flow; the app works fully on Piper alone with no key. |
| Meta MusicGen | Considered for the "catchy" car-variant music bed | No — not used, even as an opt-in default | Code (audiocraft) is MIT/Apache-2.0, but the released model weights are trained on Meta's internally licensed music library and the weights license is non-commercial (research/eval use) | See AI music — also 2-3 minutes of CPU time per 30 seconds of audio generated on hardware with no GPU, which is impractical for on-demand generation in a desktop app. |
| Curated CC0/royalty-free music loops | Default music bed for the car variant | Yes | CC0 or explicit royalty-free-for-any-use per asset | Pragmatic default — see AI music. |
| Suno / Udio (paid AI music APIs) | Optional "real AI-generated" music upgrade | No — config-gated, BYO key, off by default, flagged with ToS caveats | Commercial ToS; Suno/Udio terms restrict some commercial redistribution and are still evolving | Only ever invoked if the user supplies their own API key; the resulting track is cached locally and its ToS terms surfaced in the UI before use. |
| Freesound.org (CC0-filtered search) | Rain/thunderstorm ambience beds | Yes | CC0 per selected asset (Freesound hosts mixed licenses — only CC0-tagged sounds are used; see Asset manifest) | Assets are downloaded once during a curated "asset pack" install step (see Dependency doctor additions), not fetched live per-generation. |
ffmpeg anoisesrc (synthesized rain/thunder fallback) |
Zero-download ambience fallback | Yes | Same as ffmpeg | Filtered pink/brown noise through a lowpass + slow amplitude LFO approximates rain well enough as a no-network fallback if the user declines the CC0 asset-pack download. See Variant recipes. |
| Pixabay / Pexels (CC0 video search) | Rain/storm background loop video for MP4 variants | Yes | CC0 per selected asset | Same one-time curated download as the audio beds. |
Solid black frame (ffmpeg color source) |
Zero-download background video fallback | Yes | N/A (generated, not sourced) | No download needed at all — ffmpeg synthesizes a black frame directly. |
Bottom line: the entire default pipeline — narration, music bed, ambience, background video,
mixing, encoding — runs with zero API keys and zero payment, using tools already proven to work
on this machine (ffmpeg) or trivially installable via the existing dependency-doctor pattern
(Piper via pip install piper-tts). Every paid/cloud option is opt-in, off by default, and
never required for the feature to work end-to-end.
Variant matrix¶
Five output variants, each with an exact ffmpeg/Piper recipe. All variants start from the same
narration script (the user's study content — gap analysis, flashcard Q&A, interview-prep notes —
rendered to plain text by the existing StudyPlanService/flashcard export path) synthesized once
to a mono WAV via Piper, then mixed differently per variant.
| Variant | Format | Background | Stereo treatment | Purpose |
|---|---|---|---|---|
car-music-mp3 |
MP3 | Curated CC0 upbeat music loop, low-mixed under narration | Normal stereo (music stereo image preserved) | Alert, engaged listening while driving/commuting |
sleep-bilateral-mp3 |
MP3 | Optional very-low ambience bed (or silence) | Alternating L/R narration pan via apulsator |
Falling-asleep review with gentle side-to-side motion — not EMDR |
sleep-storm-mp3 |
MP3 | CC0 rain/thunderstorm bed (or anoisesrc synthesized fallback), narration mixed low on top |
Normal stereo | Falling-asleep review under a storm/rain bed, no panning gimmick |
tv-black-mp4 |
MP4 | Solid black 1920x1080 frame, generated (no download) | Whatever the paired MP3 audio uses | TV/monitor background: audio-forward, no visual distraction |
tv-storm-mp4 |
MP4 | CC0 looping rain/storm video, muted (video's own audio dropped) | Narration + storm-bed audio track (from sleep-storm-mp3) laid over the muted video |
TV/monitor ambience with visual rain to match the audio bed |
Common step 0 — narration to WAV (Piper)¶
# One-time per script. Model + config are the paired .onnx/.onnx.json download for the chosen voice.
echo "$NARRATION_TEXT" | piper \
--model en_US-lessac-medium.onnx \
--output_file narration.wav
Piper writes 22.05kHz (medium quality tier) or 16kHz (low tier) mono WAV. en_US-lessac-medium
is MIT-licensed and a reasonable default neutral voice; the voice is swappable via config to any
downloaded .onnx/.onnx.json pair whose license is confirmed clear (recorded in the asset
manifest, same as audio/video assets).
1. car-music-mp3¶
ffmpeg -i narration.wav -i music_loop_cc0.mp3 -filter_complex \
"[1:a]aloop=loop=-1:size=2e9,volume=0.25[music]; \
[0:a][music]amix=inputs=2:duration=first:dropout_transition=3[mix]" \
-map "[mix]" -codec:a libmp3lame -qscale:a 2 car-music.mp3
aloop=-1 loops the music bed indefinitely so it always outlasts the narration; amix with
duration=first truncates the mix to the narration's length (the music doesn't dangle past the
last word). volume=0.25 keeps the music a genuine bed, not competing with speech — tunable.
2. sleep-bilateral-mp3¶
ffmpeg -i narration.wav -filter_complex \
"[0:a]apulsator=mode=sine:hz=0.1:offset_l=0:offset_r=0.5:amount=1[panned]" \
-map "[panned]" -codec:a libmp3lame -qscale:a 2 sleep-bilateral.mp3
apulsator (confirmed present in the local ffmpeg build's filter list) amplitude-modulates the
left and right channels out of phase (offset_r=0.5 is a half-cycle offset from offset_l=0),
producing a slow sine-wave alternation between ears. hz=0.1 is a 10-second full cycle — slow
and gentle, tunable via config; nothing about the implementation invokes or approximates a
clinical EMDR bilateral-stimulation protocol (those specify precise saccade-timed alternation
under practitioner control — this is ambient background audio at a fixed, user-adjustable rate).
An optional very quiet ambience bed can be mixed in first with the same amix approach as the
car variant, at a much lower volume (e.g. 0.08–0.12), before the apulsator stage.
3. sleep-storm-mp3¶
# Preferred: curated CC0 storm bed
ffmpeg -i narration.wav -i storm_bed_cc0.mp3 -filter_complex \
"[1:a]aloop=loop=-1:size=2e9,volume=0.35[storm]; \
[0:a]volume=0.9[narr]; \
[narr][storm]amix=inputs=2:duration=first:dropout_transition=3[mix]" \
-map "[mix]" -codec:a libmp3lame -qscale:a 2 sleep-storm.mp3
# Zero-download fallback: synthesize a rain-like bed instead of storm_bed_cc0.mp3
ffmpeg -f lavfi -i "anoisesrc=color=brown:amplitude=0.6:duration=1800" \
-af "lowpass=f=3000,aeval='val(0)*(0.7+0.3*sin(2*PI*0.05*t))':c=same" \
rain_synth.wav
The synthesized fallback uses brown noise (already deeper/softer than white noise, closer to
rain's spectral character) through a lowpass filter, with a slow aeval amplitude wobble so it
doesn't sound like a flat hiss. It's a reasonable placeholder, not a claim of realism — the UI
should label it "synthesized rain (no download)" versus "recorded rain (CC0)" so the user knows
which they picked.
4. tv-black-mp4¶
ffmpeg -f lavfi -i "color=c=black:s=1920x1080:r=30" -i car-music.mp3 \
-shortest -c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 192k tv-black.mp4
Solid black frame generated on the fly (color=c=black, no download, no asset to license) paired
with whichever MP3 variant's audio the user picked; -shortest ends the video with the audio
instead of looping black forever.
5. tv-storm-mp4¶
ffmpeg -stream_loop -1 -i storm_video_cc0.mp4 -i sleep-storm.mp3 \
-map 0:v -map 1:a -shortest -c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 192k tv-storm.mp4
-stream_loop -1 loops the CC0 background video indefinitely; -map 0:v -map 1:a explicitly
takes video from the loop and audio from the storm-bed MP3 (dropping the source video's own
audio track, since it's the narration+bed mix that matters, not whatever sound the stock clip
shipped with). -shortest again bounds the output to the audio's length.
Metadata disclaimer tagging¶
Every encode adds the relevant safety/no-medical-claim disclaimer as a comment tag so it travels with the file even after it leaves WorkWingman (e.g. copied to a phone):
# MP3
-metadata comment="Not a medical treatment. Relaxation ambience only. See workwingman docs."
# MP4
-metadata comment="Same disclaimer text"
Asset manifest & licensing¶
A checked-in manifest (assets/study-audio/manifest.json, one entry per bundled/downloadable
asset) records, per asset: source URL, author, exact license (must be CC0 or explicit
royalty-free-for-any-use), and which variant(s) use it. No asset is added to the default config
without a manifest row. Example shape:
{
"id": "rain-bed-01",
"kind": "audio-bed",
"title": "Rain Loop",
"author": "qubodup",
"source": "https://freesound.org/people/qubodup/sounds/212580/",
"license": "CC0 1.0",
"usedBy": ["sleep-storm-mp3", "tv-storm-mp4"]
}
This is a design placeholder — populating it with real vetted CC0 picks (confirming each Freesound/Pixabay/Pexels asset is actually tagged CC0, not just "free to use with attribution") is explicit build-agent work, not done in this research pass.
Dependency doctor additions¶
WorkWingman already has a preflight IDependencyDoctor
(read-only detect on every launch; install/repair only on explicit per-item user approval — see
DependencyDoctor.cs and
the manifest in DependencyManifest.cs).
This feature adds to that same manifest rather than inventing a new install path:
| Id | Purpose | Detect | Install method | Optional? |
|---|---|---|---|---|
ffmpeg |
Mixing/panning/encoding all study-audio variants | ffmpeg -version |
Winget (Gyan.FFmpeg) — already present on this dev machine, confirming the winget path works |
No — required once the user opens the study-audio feature; optional at global app launch (most users may never touch this feature) |
piper |
Local narration TTS | piper --version |
pip install piper-tts (no winget package exists; Python must already be present or gets its own dependency-doctor entry — python/py detection via python --version) |
No, same scoping as ffmpeg above |
study-audio-assets |
The curated CC0 asset pack (rain/storm/music/video loops) | Presence check on the app's assets/study-audio/ cache folder against manifest.json |
Bulk download of manifest-listed CC0 URLs into the cache folder, with a single approval gate ("Download N study-audio assets, ~X MB, all CC0 — proceed?") | Yes — the synthesized (anoisesrc) and solid-black fallbacks work with zero downloads, so this is skippable |
Both ffmpeg and piper follow the existing pattern exactly: detection is a read-only
--version probe; install only fires for ids the user explicitly approved that are
Missing/Outdated; a fresh probe re-verifies after install. The one new wrinkle is piper's
install method — there's no winget package for it, so InstallMethod gains a Pip case
(analogous to the existing Npm case for the claude CLI dependency) that shells pip install
piper-tts the same way Npm shells npm install -g.
Seam interfaces (design placeholders — not implemented)¶
These are the C# interfaces the build agents implement against. They are declared here as the contract, not built out — no ffmpeg/Piper process-shelling logic exists yet anywhere in the codebase for this feature.
namespace WorkWingman.Core.Models;
public enum StudyAudioVariant
{
CarMusicMp3,
SleepBilateralMp3,
SleepStormMp3,
TvBlackMp4,
TvStormMp4
}
/// <summary>One requested render: which job's study content, which variant, optional tuning.</summary>
public class StudyMediaRequest
{
public string JobId { get; set; } = string.Empty;
public StudyAudioVariant Variant { get; set; }
/// <summary>Narration source text — defaults to the job's StudyPlan/flashcard content if empty.</summary>
public string? NarrationText { get; set; }
/// <summary>Piper voice id (.onnx model name); falls back to the configured default voice.</summary>
public string? VoiceId { get; set; }
}
public class StudyMediaResult
{
public string OutputPath { get; set; } = string.Empty;
public StudyAudioVariant Variant { get; set; }
public TimeSpan Duration { get; set; }
/// <summary>Which CC0 assets (by manifest id) this render used, for license traceability.</summary>
public IReadOnlyList<string> AssetIdsUsed { get; set; } = [];
/// <summary>The exact disclaimer text embedded in the file's metadata comment tag.</summary>
public string DisclaimerText { get; set; } = string.Empty;
}
namespace WorkWingman.Core.Interfaces;
using WorkWingman.Core.Models;
/// <summary>
/// Generates a portable study-audio/video file for one variant from a job's study content.
/// Implementations shell out to Piper (narration) and ffmpeg (mixing/panning/encoding) — never
/// call a network service in the default configuration. All output stays on local disk.
/// </summary>
public interface IStudyMediaGenerator
{
/// <summary>The variants this generator can currently produce (lets the UI grey out anything
/// whose dependency — ffmpeg, Piper, or the CC0 asset pack — isn't installed yet).</summary>
IReadOnlyList<StudyAudioVariant> SupportedVariants { get; }
Task<StudyMediaResult> GenerateAsync(StudyMediaRequest request, CancellationToken ct = default);
}
Two smaller seams the build agents will also need, sketched here so the split below has concrete boundaries rather than one monolithic interface:
/// <summary>Synthesizes narration text to a local WAV file via Piper (or eSpeak-ng fallback).</summary>
public interface INarrationSynthesizer
{
Task<string> SynthesizeToWavAsync(string text, string? voiceId, CancellationToken ct = default);
}
/// <summary>Resolves manifest-listed CC0 assets to local file paths, downloading on first use
/// (behind the dependency-doctor approval gate) and caching thereafter.</summary>
public interface IStudyAssetLibrary
{
Task<string?> ResolveAssetPathAsync(string assetId, CancellationToken ct = default);
IReadOnlyList<StudyAssetInfo> ListAssets(string? kind = null);
}
Build-agent breakdown¶
Five focused build agents, split so their file sets don't overlap and each can land as its own branch/PR without blocking the others. Suggested order: 1, 4, and 5 first (nothing else compiles, runs, or is safe to ship audio from without the dependency wiring, the seam types, and the verification gate), then 2 and 3 in parallel once those land.
1. dependency-wiring¶
- Adds
ffmpeg,piper,python(if not already probed elsewhere), andstudy-audio-assetstoDependencyManifest.cs. - Adds the
Pipcase toInstallMethodand its shell-out inDependencyDoctor.cs(InstallAsync), mirroring the existingNpmcase. - Implements
IStudyAssetLibrary's download/cache step (bulk-fetch manifest URLs intoassets/study-audio/, single approval gate through the existing dependency-doctor UI flow). - Touches:
WorkWingman.Core/Interfaces/IDependencyDoctor.cs(if the contract needs extending),WorkWingman.Infrastructure/Dependencies/*, a newassets/study-audio/manifest.json. - Does not touch ffmpeg/Piper invocation logic itself — just gets the binaries and assets onto disk and detectable.
2. audio-pipeline¶
- Implements
INarrationSynthesizer(Piper subprocess call, eSpeak-ng fallback) and the three MP3 variants' ffmpeg recipes (car-music-mp3,sleep-bilateral-mp3,sleep-storm-mp3), including theanoisesrcsynthesized-rain fallback path when the asset pack isn't installed. - Owns metadata/disclaimer tagging (
-metadata comment=...) for every MP3 it produces. - Consumes already-verified script text only — takes a plain-text script as input and never
calls
IStudyContentVerifieritself; that gate runs upstream (agent 5) before this agent ever sees the text. Keeps narration/mixing free of verification-loop concerns. - Touches: a new
WorkWingman.Infrastructure/StudyAudio/folder (NarrationSynthesizer.cs,AudioMixer.csor similar) plus its own test project entries. Does not touch video/MP4 code.
3. video-assembly¶
- Implements the two MP4 variants (
tv-black-mp4generated black frame;tv-storm-mp4looping CC0 video + muted source audio + the storm-bed MP3 track from agent 2's output). - Depends on agent 2's MP3 output existing (takes a finished MP3 path as input, does not
re-implement narration/mixing) — the two agents share the
StudyMediaResultcontract but not code. - Touches:
WorkWingman.Infrastructure/StudyAudio/VideoAssembler.cs(new), reusesIStudyAssetLibraryfrom agent 1 for the background video loop.
4. api-and-seams¶
- Declares the actual (not just sketched)
IStudyMediaGenerator/INarrationSynthesizer/IStudyAssetLibraryinterfaces and models inWorkWingman.Core, wires DI inProgram.cs, and adds aStudyAudioController(api/study/{jobId}/audio) following the existingStudyControllerpattern — request a variant, poll/await the render, return the output path. - This agent's output is what agents 2 and 3 implement against, so it should land first or be
stubbed early (empty implementations returning
NotImplementedException) so the others aren't blocked on interface bikeshedding mid-flight. - Touches:
WorkWingman.Core/Interfaces/*,WorkWingman.Core/Models/*,WorkWingman.Api/Controllers/StudyAudioController.cs,WorkWingman.Api/Program.cs.
5. content-verification-gate¶
- Implements
IStudyContentVerifier,TechnicalClaim/VerificationResultmodels, the claim extraction step, and the two verifier transports:CodexStudyContentVerifier(default, free — shellscodex exec, reusing the existingIAdvisorTransportpattern frompageagent-advisor-design.mdrather than inventing a new one) andGeminiStudyContentVerifier(optional, config-gated BYO key). - Also implements the script-generation step that produces
ELI5/pattern-recognition content and calls the
verifier before handing approved text to the audio pipeline — grounded against
InterviewPrepBank, the learning-paths catalog, and the job's Claude Project when one exists. - Deliberately app-wide and framework-agnostic — this agent's output is consumed by
study-audio (agent 2's input) but is not itself audio code, so it should land as its own
interface in
WorkWingman.Corewith its own test coverage (mocked verifier transports, Bogus-seeded claims, assertions on Verified/Corrected/Flagged handling — never asserting on a real model call). - Touches:
WorkWingman.Core/Interfaces/IStudyContentVerifier.cs,WorkWingman.Core/Models/TechnicalClaim.cs(+VerificationResult),WorkWingman.Infrastructure/Verification/*(new), its own test project entries. - This agent should land before or alongside agent 2, not after — audio-pipeline's contract
is "accept already-verified text," so the gate needs to exist (even as a stub returning
Verifiedfor everything, clearly marked TODO) before agent 2's tests can meaningfully assert the boundary.
Frontend UI (a "Study audio" panel alongside the existing flashcard/study views, variant picker,
generate button, disclaimer display, download link, and a visible "unverified — confirm
independently" marker for any Flagged content that made it through) is deliberately not one
of these five — it's a sixth pass once the backend variants exist and are demoable, so the
frontend agent has real output to point at instead of guessing the API shape.
Privacy note¶
Generation is 100% local by default: Piper and ffmpeg run as subprocesses on the user's own machine, reading the user's own study content (already resident in the app's data) and writing output files back to local disk. No network call is made in the default configuration. The only data that ever leaves the machine is if the user explicitly opts into a config-gated BYO-key cloud TTS or paid AI-music provider — and even then, only the narration text or a music prompt is sent, never the full resume/profile/job data.
Related docs¶
- prep-studio.md — the existing in-browser flashcard TTS this feature complements (not replaces)
- architecture.md — the standing BYO-key/config-gate convention
- testing.md — where this feature's future tests will slot in (mocked Piper/ffmpeg subprocess calls, no real audio/video rendered in CI)
- Plain-language version: ../plain/study-audio.md