Skip to content

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 — see WorkWingman.Core/Interfaces/IStudyContentVerifier.cs and WorkWingman.Infrastructure/Verification/. The registered default is CompositeStudyContentVerifier: a free, instant, no-external-process DeterministicStudyContentVerifier (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 to CodexStudyContentVerifier — a real, working codex exec CLI shell-out (config-gated via WorkWingman:StudyContentVerification:UseCodex, off by default) — only when the deterministic check can't resolve the claim (Flagged) and Codex verification is enabled. GeminiStudyContentVerifier remains a documented-but-not-built config-gated upgrade option, same as CodexStudyContentVerifier was before this pass. The gate's first real consumer is FlashcardService's TechConcept cards (see ApplyVerifiedComplexityHintsAsync): 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.
  • 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-onboarding branch 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 + CodexStudyContentVerifier today.

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

  1. Generate. Claude drafts the script content (ELI5 explanation or pattern-recognition phrasing) from the user's gap analysis / prep-bank / flashcard source.
  2. 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.
  3. Independent-model verify. Each claim is sent to a different model than the one that generated it:
  4. Default, free-first: a CodexStudyContentVerifier shelling the codex exec CLI — already an installed, working transport in this codebase (see IAdvisorTransport and its codex exec implementation for CodexPageAdvisor in pageagent-advisor-design.md — the same transport pattern is reused here, not reinvented).
  5. 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.
  6. 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 InterviewPrepBank content, the learning-paths resource catalog, and — when one exists for the job — the user's own Claude Project / NotebookLM-style grounded workspace (see IStudyPlanService.GenerateClaudeStudyProjectAsync). These are the ground truth the verifier model reasons against, not just "does this sound right" model-to-model agreement.
  7. Resolve.
  8. Agreement (generator and verifier(s) concur, consistent with curated sources) → Verified, ships as-is.
  9. Verifier catches a clear errorCorrected, the fixed text ships (with the correction logged for audit — silent overwrite of a wrong claim is fine, silent disagreement is not).
  10. Models disagree and it can't be mechanically resolvedFlagged. 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.
  11. 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), and study-audio-assets to DependencyManifest.cs.
  • Adds the Pip case to InstallMethod and its shell-out in DependencyDoctor.cs (InstallAsync), mirroring the existing Npm case.
  • Implements IStudyAssetLibrary's download/cache step (bulk-fetch manifest URLs into assets/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 new assets/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 the anoisesrc synthesized-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 IStudyContentVerifier itself; 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.cs or similar) plus its own test project entries. Does not touch video/MP4 code.

3. video-assembly

  • Implements the two MP4 variants (tv-black-mp4 generated black frame; tv-storm-mp4 looping 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 StudyMediaResult contract but not code.
  • Touches: WorkWingman.Infrastructure/StudyAudio/VideoAssembler.cs (new), reuses IStudyAssetLibrary from agent 1 for the background video loop.

4. api-and-seams

  • Declares the actual (not just sketched) IStudyMediaGenerator/INarrationSynthesizer/ IStudyAssetLibrary interfaces and models in WorkWingman.Core, wires DI in Program.cs, and adds a StudyAudioController (api/study/{jobId}/audio) following the existing StudyController pattern — 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/VerificationResult models, the claim extraction step, and the two verifier transports: CodexStudyContentVerifier (default, free — shells codex exec, reusing the existing IAdvisorTransport pattern from pageagent-advisor-design.md rather than inventing a new one) and GeminiStudyContentVerifier (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.Core with 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 Verified for 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.

  • 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