diff --git a/docs/relaunch/DECISIONS.md b/docs/relaunch/DECISIONS.md
index ff5978d..6f7c01b 100644
--- a/docs/relaunch/DECISIONS.md
+++ b/docs/relaunch/DECISIONS.md
@@ -3,6 +3,12 @@
Agents append one entry per tie-break decision (01-product.md §0.4). Newest first.
Format: `YYYY-MM-DD · WP-XX · decision · why · alternatives considered`.
+- 2026-10-27 · WP-07 · deterministic voice learn/preview added at the lib/ai boundary (`learnVoiceProfileDeterministic`/`previewVoiceDeterministic` in `lib/ai/voice.ts`): learn tries the §8.1 provider chain first, and only when every provider fails derives the profile extractively from the pasted samples (person from first-person pronouns, sentence length from average words/sentence, emoji from pictograph counts); fields a lexicon-free analyzer cannot honestly infer (tone, formality/energy/humor, signature phrases, ctaStyle) stay at the §7.4 defaults, and both the learn response (`status:"deterministic"`) and the UI ("Basic profile/version — our AI writer was unavailable.") label the result · U3's ≤15 s budget must hold with no usable AI keys; hard rule: never fake a learned/AI profile when no AI ran — labelled basic, not silent default · returning the plain §7.4 default with no label; forking WP-04's learn loop
+- 2026-10-27 · WP-07 · learn/preview provider calls cap the timeout at 12 s (`VOICE_TIMEOUT_MS`) instead of §8.1's 60 s · both are interactive (a user is waiting on step 2 / the preview card) and U3 budgets 15 s end-to-end; 60 s targets background generation · keeping the 60 s default
+- 2026-10-27 · WP-07 · Skip never writes anything: a null `shows.voice_profile` resolves to the §7.4 default at read time via `resolveShowVoiceProfile()` (unit-tested field-for-field) · one source of truth for the default, and nothing false is persisted after Skip · writing the default profile on Skip
+- 2026-10-27 · WP-07 · tone stays exactly 3 chips (§7.4 `tone[3]`): removing a chip disables Continue/Save voice until a replacement is added ("Add a tone word.") · the WP-04 schema fixes the length and silently padding would fabricate a value · relaxing the schema to 1–3
+- 2026-10-27 · WP-07 · learned samples persist to `shows.voice_samples` as raw pasted text; the MK-8 samples list therefore shows a text snippet + word count instead of MK-8's user labels ("Ep 39 show notes") · §7.1 has no label column and adding one is out of MVP scope · adding a labels schema field
+
- 2026-10-27 · WP-05 · Live ASR smoke test skipped: `.env.relaunch` holds sanitized placeholders (values literally `"[SENSITIVE]"`, no `gsk_` key, ASR_PROVIDER ≠ groq) · the handoff rule says skip the live call when ASR_PROVIDER≠groq; the Groq adapter is covered by fetch-injected unit tests (request shape, error mapping, language gate) instead · running a live call with a placeholder key
- 2026-10-27 · WP-05 · Chunk manifest (path + offsetMs per ≤10-min chunk) serialized as JSON into `episodes.audio_blob_path` · §7.1 has a single text column and the server must re-run transcription idempotently after a refresh without client state (U4); a plain single-path string still parses as one chunk at 0 ms · new table/column (schema is WP-02's), body-only manifest (lost on refresh)
- 2026-10-27 · WP-05 · POST /api/episodes accepts `blobPaths[]` + parallel `chunkOffsetsMs[]` beyond the §7.5 body shape · §8.3 requires per-chunk offsets for the overlap dedupe and §7.5 pins `blobPaths[]`; parallel arrays are the smallest coherent extension · nested `chunks[]` objects in create (used only on transcribe), path-embedded offsets
diff --git a/web/app/(app)/app/onboarding/voice/page.tsx b/web/app/(app)/app/onboarding/voice/page.tsx
index 93f92a4..19368fa 100644
--- a/web/app/(app)/app/onboarding/voice/page.tsx
+++ b/web/app/(app)/app/onboarding/voice/page.tsx
@@ -1,26 +1,22 @@
-import Link from "next/link";
+import { redirect } from "next/navigation";
+import { VoiceOnboardingForm } from "@/components/app/onboarding/voice-form";
import { StepDots } from "@/components/app/step-dots";
+import { requireUser } from "@/lib/auth";
+import { isDbConfigured } from "@/lib/env";
+import { getShowByOwnerSafe } from "@/lib/show/queries-safe";
-export const metadata = { title: "Onboarding · EpisodeKit" };
+export const metadata = { title: "Teach EpisodeKit your voice · EpisodeKit" };
-/**
- * Step 2 placeholder — the voice-learning UI ships in WP-07 (U3).
- * The route exists so the onboarding flow is complete end to end.
- */
-export default function OnboardingVoicePage() {
+/** Onboarding step 2 (M1): voice samples → profile (U3, MK-2). */
+export default async function OnboardingVoicePage() {
+ const userId = await requireUser();
+ const show = await getShowByOwnerSafe(userId);
+ if (!show && isDbConfigured()) redirect("/app/onboarding/show");
return (
-
-
Teach EpisodeKit your voice
-
This page is coming soon.
-
- Skip for now
-
+
+
);
diff --git a/web/app/(app)/app/settings/voice/page.tsx b/web/app/(app)/app/settings/voice/page.tsx
index 2a1166d..5ab0a76 100644
--- a/web/app/(app)/app/settings/voice/page.tsx
+++ b/web/app/(app)/app/settings/voice/page.tsx
@@ -1,11 +1,21 @@
+import { redirect } from "next/navigation";
+import { VoiceSettingsForm } from "@/components/app/settings/voice-form";
+import { requireUser } from "@/lib/auth";
+import { isDbConfigured } from "@/lib/env";
+import { getShowByOwnerSafe } from "@/lib/show/queries-safe";
+import { resolveShowVoiceProfile } from "@/lib/show/voice";
+
export const metadata = { title: "Settings · EpisodeKit" };
-/** Voice settings ship in WP-07 (M9 Voice, MK-8). */
-export default function SettingsVoicePage() {
+/** Settings → Voice (M9, MK-8): §7.4 profile editor, samples, preview. */
+export default async function SettingsVoicePage() {
+ const userId = await requireUser();
+ const show = await getShowByOwnerSafe(userId);
+ if (!show && isDbConfigured()) redirect("/app/onboarding/voice");
return (
-
-
Voice
-
This page is coming soon.
-
+
);
}
diff --git a/web/app/api/show/voice/learn/route.ts b/web/app/api/show/voice/learn/route.ts
new file mode 100644
index 0000000..bfd7c16
--- /dev/null
+++ b/web/app/api/show/voice/learn/route.ts
@@ -0,0 +1,14 @@
+// §7.5 POST /api/show/voice/learn — onboarding step 2 (U3).
+import { getAuthUserId } from "@/lib/auth";
+import { AUTH_REQUIRED_COPY } from "@/lib/copy";
+import { handleVoiceLearn } from "@/lib/show/voice";
+
+export async function POST(req: Request) {
+ const userId = await getAuthUserId();
+ if (!userId) {
+ return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
+ }
+ const body: unknown = await req.json().catch(() => null);
+ const result = await handleVoiceLearn(userId, body);
+ return Response.json(result.json, { status: result.status });
+}
diff --git a/web/app/api/show/voice/preview/route.ts b/web/app/api/show/voice/preview/route.ts
new file mode 100644
index 0000000..ff5bc39
--- /dev/null
+++ b/web/app/api/show/voice/preview/route.ts
@@ -0,0 +1,14 @@
+// §7.5 POST /api/show/voice/preview — Settings → Voice preview (M9/MK-8).
+import { getAuthUserId } from "@/lib/auth";
+import { AUTH_REQUIRED_COPY } from "@/lib/copy";
+import { handleVoicePreview } from "@/lib/show/voice";
+
+export async function POST(req: Request) {
+ const userId = await getAuthUserId();
+ if (!userId) {
+ return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
+ }
+ const body: unknown = await req.json().catch(() => null);
+ const result = await handleVoicePreview(userId, body);
+ return Response.json(result.json, { status: result.status });
+}
diff --git a/web/components/app/onboarding/voice-form.tsx b/web/components/app/onboarding/voice-form.tsx
new file mode 100644
index 0000000..bbc1e5a
--- /dev/null
+++ b/web/components/app/onboarding/voice-form.tsx
@@ -0,0 +1,228 @@
+"use client";
+
+// Onboarding step 2 (M1, MK-2, U3): paste 1–3 past show notes (≥150 words
+// each), "Learn my voice" → chips/sliders, or "Skip for now" → the §7.4
+// default stays (null profile). The deterministic no-AI answer is labelled;
+// keyless previews say saving is unavailable instead of faking a save.
+import { useRouter } from "next/navigation";
+import { useMemo, useState } from "react";
+import type { VoiceProfile } from "@/lib/ai/voice";
+import {
+ SAVE_UNAVAILABLE_COPY,
+ SAMPLE_TOO_SHORT_COPY,
+ VOICE_BASIC_PROFILE_COPY,
+} from "@/lib/copy";
+import { MIN_SAMPLE_WORDS, countWords } from "@/lib/words";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { profileComplete, VoiceProfileEditor } from "@/components/app/voice/profile-editor";
+import { VoicePreviewCard } from "@/components/app/voice/preview-card";
+
+type LearnResponse = {
+ status?: "learned" | "deterministic";
+ profile?: VoiceProfile;
+ saved?: boolean;
+ error?: string;
+};
+
+export function VoiceOnboardingForm() {
+ const router = useRouter();
+ const [samples, setSamples] = useState
([""]);
+ const [phase, setPhase] = useState<"collect" | "learned">("collect");
+ const [profile, setProfile] = useState(null);
+ const [learnSaved, setLearnSaved] = useState(false);
+ const [deterministic, setDeterministic] = useState(false);
+ const [learning, setLearning] = useState(false);
+ const [continuing, setContinuing] = useState(false);
+ const [error, setError] = useState(null);
+
+ const filled = useMemo(
+ () => samples.map((s) => s.trim()).filter((s) => s.length > 0),
+ [samples],
+ );
+ const canLearn = filled.length > 0 && filled.every((s) => countWords(s) >= MIN_SAMPLE_WORDS);
+
+ function updateSample(index: number, value: string) {
+ setSamples((current) => current.map((s, i) => (i === index ? value : s)));
+ }
+
+ async function learn() {
+ if (learning || !canLearn) return;
+ setError(null);
+ setLearning(true);
+ try {
+ const res = await fetch("/api/show/voice/learn", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ samples: filled }),
+ });
+ const data = (await res.json().catch(() => ({}))) as LearnResponse;
+ if (!res.ok || !data.profile) {
+ setError(data.error ?? "Something went wrong. Try again.");
+ return;
+ }
+ setProfile(data.profile);
+ setLearnSaved(data.saved === true);
+ setDeterministic(data.status === "deterministic");
+ setPhase("learned");
+ } catch {
+ setError("Something went wrong. Try again.");
+ } finally {
+ setLearning(false);
+ }
+ }
+
+ async function continueToFirstEpisode() {
+ if (!profile || continuing) return;
+ setContinuing(true);
+ try {
+ if (learnSaved) {
+ const res = await fetch("/api/show", {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ voiceProfile: profile }),
+ });
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ if (res.status === 503) {
+ setError(SAVE_UNAVAILABLE_COPY);
+ } else {
+ setError(data.error ?? "Something went wrong. Try again.");
+ }
+ setContinuing(false);
+ return;
+ }
+ }
+ router.push("/app/onboarding/first");
+ } catch {
+ setError("Something went wrong. Try again.");
+ setContinuing(false);
+ }
+ }
+
+ if (phase === "learned" && profile) {
+ return (
+
+
+
Here's your voice
+
+ Adjust anything. You can change this later in Settings.
+
+
+
+ {deterministic ? (
+
+ {VOICE_BASIC_PROFILE_COPY}
+
+ ) : null}
+ {!learnSaved ? (
+
+ {SAVE_UNAVAILABLE_COPY}
+
+ ) : null}
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ router.push("/app/onboarding/show")}
+ data-testid="back-to-show"
+ >
+ ← Back
+
+
+ {continuing ? "Saving…" : "Continue"}
+
+
+
+ );
+ }
+
+ return (
+
+
+
Teach EpisodeKit your voice
+
+ Paste 1–3 past show notes or newsletters. We'll match how you write.
+
+
+
+ {samples.map((sample, index) => {
+ const words = countWords(sample);
+ return (
+
+
+ Example {index + 1}
+
+
+ );
+ })}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {filled.length > 0 && !canLearn ? (
+
+ {SAMPLE_TOO_SHORT_COPY}
+
+ ) : null}
+
+
+
+ {learning ? "Learning your voice…" : "Learn my voice"}
+
+ router.push("/app/onboarding/first")}
+ data-testid="skip-voice"
+ >
+ Skip for now
+
+
+
+ );
+}
diff --git a/web/components/app/settings/voice-form.tsx b/web/components/app/settings/voice-form.tsx
new file mode 100644
index 0000000..2c63433
--- /dev/null
+++ b/web/components/app/settings/voice-form.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+// Settings → Voice (M9, MK-8): left — profile editor + writing samples +
+// [Save voice]; right — preview card with [Update preview]. Everything saves
+// through PATCH /api/show (voiceProfile + voiceSamples), validated by the
+// shared §7.4 schema server-side.
+import { useState } from "react";
+import type { VoiceProfile } from "@/lib/ai/voice";
+import { SAVE_UNAVAILABLE_COPY } from "@/lib/copy";
+import { MIN_SAMPLE_WORDS, countWords } from "@/lib/words";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { profileComplete, VoiceProfileEditor } from "@/components/app/voice/profile-editor";
+import { VoicePreviewCard } from "@/components/app/voice/preview-card";
+
+type SaveResponse = { show?: unknown; error?: string };
+
+function snippet(text: string): string {
+ const firstLine = text.trim().split("\n")[0] ?? "";
+ return firstLine.length > 44 ? `${firstLine.slice(0, 44).trimEnd()}…` : firstLine;
+}
+
+export function VoiceSettingsForm({
+ profile: initialProfile,
+ samples: initialSamples,
+}: {
+ profile: VoiceProfile;
+ samples: string[];
+}) {
+ const [profile, setProfile] = useState(initialProfile);
+ const [samples, setSamples] = useState(initialSamples);
+ const [adding, setAdding] = useState(false);
+ const [draft, setDraft] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState(null);
+
+ const draftWords = countWords(draft);
+ const draftValid = draftWords >= MIN_SAMPLE_WORDS;
+
+ function addDraft() {
+ if (!draftValid) return;
+ setSamples((current) => [...current, draft.trim()]);
+ setDraft("");
+ setAdding(false);
+ setSaved(false);
+ }
+
+ function removeSample(index: number) {
+ setSamples((current) => current.filter((_, i) => i !== index));
+ setSaved(false);
+ }
+
+ async function save() {
+ if (saving) return;
+ setError(null);
+ setSaved(false);
+ setSaving(true);
+ try {
+ const res = await fetch("/api/show", {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ voiceProfile: profile, voiceSamples: samples }),
+ });
+ const data = (await res.json().catch(() => ({}))) as SaveResponse;
+ if (res.status === 503) {
+ setError(SAVE_UNAVAILABLE_COPY);
+ return;
+ }
+ if (!res.ok) {
+ setError(data.error ?? "Something went wrong. Try again.");
+ return;
+ }
+ setSaved(true);
+ } catch {
+ setError("Something went wrong. Try again.");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
Writing samples
+ {samples.map((sample, index) => (
+
+
+ {snippet(sample)} · {countWords(sample)} words
+
+ removeSample(index)}
+ data-testid={`remove-sample-${index}`}
+ >
+ Remove
+
+
+ ))}
+ {adding ? (
+
+ ) : (
+
setAdding(true)}
+ disabled={samples.length >= 3}
+ data-testid="add-sample"
+ >
+ + Add sample
+
+ )}
+
+
+
+
+ {saving ? "Saving…" : "Save voice"}
+
+ Used for every new episode.
+ {saved ? (
+
+ Saved
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/web/components/app/voice/chip-row.tsx b/web/components/app/voice/chip-row.tsx
new file mode 100644
index 0000000..65497e6
--- /dev/null
+++ b/web/components/app/voice/chip-row.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+// MK-2/MK-8 chip + segmented primitives (mock.css .chip/.seg, §5.2 tokens).
+import { useState, type KeyboardEvent } from "react";
+import { PlusIcon, XIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+/** Removable chip list with an inline "+ Add" flow (tone, Never say, …). */
+export function ChipList({
+ label,
+ items,
+ testIdPrefix,
+ onRemove,
+ onAdd,
+ max,
+ maxedCopy,
+}: {
+ label: string;
+ items: string[];
+ testIdPrefix: string;
+ onRemove: (item: string) => void;
+ onAdd: (item: string) => void;
+ max: number;
+ maxedCopy?: string;
+}) {
+ const [adding, setAdding] = useState(false);
+ const [draft, setDraft] = useState("");
+ const full = items.length >= max;
+
+ function commit() {
+ const value = draft.trim();
+ setDraft("");
+ setAdding(false);
+ if (!value) return;
+ if (items.some((i) => i.toLowerCase() === value.toLowerCase())) return;
+ onAdd(value);
+ }
+
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ commit();
+ } else if (event.key === "Escape") {
+ setDraft("");
+ setAdding(false);
+ }
+ }
+
+ return (
+
+
{label}
+
+ {items.map((item) => (
+
+ {item}
+ onRemove(item)}
+ className="text-faint transition-colors hover:text-danger"
+ >
+
+
+
+ ))}
+ {adding && !full ? (
+
setDraft(e.target.value)}
+ onBlur={commit}
+ onKeyDown={onKeyDown}
+ aria-label={`New ${label.toLowerCase()} term`}
+ className="h-7 w-36 py-0 text-small"
+ maxLength={60}
+ />
+ ) : (
+
setAdding(true)}
+ >
+ Add
+
+ )}
+
+
+ );
+}
+
+/** §MK-2/MK-8 segmented control (Emoji, Write as, Sentence length). */
+export function Segmented({
+ label,
+ value,
+ options,
+ testIdPrefix,
+ onChange,
+}: {
+ label: string;
+ value: T;
+ options: Array<{ value: T; label: string }>;
+ testIdPrefix: string;
+ onChange: (value: T) => void;
+}) {
+ return (
+
+
{label}
+
+ {options.map((option) => (
+ onChange(option.value)}
+ className={`px-3 py-1.5 text-small transition-colors ${
+ value === option.value ? "bg-accent-soft font-medium text-accent" : "text-body hover:bg-surface-2"
+ }`}
+ >
+ {option.label}
+
+ ))}
+
+
+ );
+}
+
+/** Stable DOM/testid slug for a chip value. */
+export function slug(value: string): string {
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "term";
+}
diff --git a/web/components/app/voice/preview-card.tsx b/web/components/app/voice/preview-card.tsx
new file mode 100644
index 0000000..ece0413
--- /dev/null
+++ b/web/components/app/voice/preview-card.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+// MK-2/MK-8 preview card: a short sample paragraph written in the saved (or
+// current) voice. [Update preview] posts the live editor state to
+// /api/show/voice/preview; a deterministic (no-AI) answer is labelled with
+// the §6 fallback copy instead of passing as model text.
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { VOICE_BASIC_PREVIEW_COPY } from "@/lib/copy";
+
+type PreviewRequestProfile = {
+ tone: string[];
+ formality: number;
+ energy: number;
+ humor: number;
+ person: string;
+ sentenceLength: string;
+ emoji: string;
+ neverSay: string[];
+};
+
+type PreviewResponse = {
+ status?: "ai" | "deterministic";
+ text?: string;
+ error?: string;
+};
+
+export function VoicePreviewCard({
+ profile,
+ autoLoad = false,
+}: {
+ profile: PreviewRequestProfile;
+ autoLoad?: boolean;
+}) {
+ const [text, setText] = useState(null);
+ const [basic, setBasic] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [failed, setFailed] = useState(false);
+ const autoLoaded = useRef(false);
+
+ const update = useCallback(async () => {
+ setLoading(true);
+ setFailed(false);
+ try {
+ const res = await fetch("/api/show/voice/preview", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ profile }),
+ });
+ const data = (await res.json().catch(() => ({}))) as PreviewResponse;
+ if (!res.ok || !data.text) {
+ setFailed(true);
+ return;
+ }
+ setText(data.text);
+ setBasic(data.status === "deterministic");
+ } catch {
+ setFailed(true);
+ } finally {
+ setLoading(false);
+ }
+ }, [profile]);
+
+ useEffect(() => {
+ if (autoLoaded.current || !autoLoad) return;
+ autoLoaded.current = true;
+ void update();
+ }, [autoLoad, update]);
+
+ return (
+
+
PREVIEW
+ {text !== null ? (
+
+ {text}
+
+ ) : (
+
+ )}
+
+ {loading ? "Writing a preview." : text ? "Preview ready." : ""}
+
+ {basic ? (
+
+ {VOICE_BASIC_PREVIEW_COPY}
+
+ ) : null}
+ {failed ? (
+
+ Something went wrong. Try again.
+
+ ) : null}
+
+
+ {loading ? "Writing…" : "Update preview"}
+
+
+
+ );
+}
diff --git a/web/components/app/voice/profile-editor.tsx b/web/components/app/voice/profile-editor.tsx
new file mode 100644
index 0000000..c76b28a
--- /dev/null
+++ b/web/components/app/voice/profile-editor.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+// §7.4 voice profile editor shared by onboarding step 2 (MK-2 after-learning)
+// and Settings → Voice (MK-8): tone chips, 1–5 sliders, segmented options,
+// Never say / Always include chips. Pure controlled component — persistence
+// belongs to the forms that render it.
+import type { VoiceProfile } from "@/lib/ai/voice";
+import { VOICE_TONE_HINT_COPY } from "@/lib/copy";
+import { Slider } from "@/components/ui/slider";
+import { ChipList, Segmented } from "./chip-row";
+
+const SLIDERS = [
+ { key: "formality", label: "Formality" },
+ { key: "energy", label: "Energy" },
+ { key: "humor", label: "Humor" },
+] as const;
+
+export function VoiceProfileEditor({
+ profile,
+ onChange,
+ showAlwaysInclude = false,
+}: {
+ profile: VoiceProfile;
+ onChange: (next: VoiceProfile) => void;
+ showAlwaysInclude?: boolean;
+}) {
+ const set = (key: K, value: VoiceProfile[K]) =>
+ onChange({ ...profile, [key]: value });
+
+ return (
+
+
set("tone", profile.tone.filter((t) => t !== item))}
+ onAdd={(item) => set("tone", [...profile.tone, item])}
+ />
+
+
+ {SLIDERS.map(({ key, label }) => (
+
+ {label}
+ set(key, v)}
+ ariaLabel={label}
+ data-testid={`slider-${key}`}
+ />
+
+ {profile[key]}
+
+
+ ))}
+
+
+
+ set("emoji", v)}
+ options={[
+ { value: "none", label: "None" },
+ { value: "light", label: "Light" },
+ { value: "heavy", label: "Heavy" },
+ ]}
+ />
+ set("person", v)}
+ options={[
+ { value: "I", label: "I" },
+ { value: "we", label: "We" },
+ { value: "they", label: "They" },
+ ]}
+ />
+ set("sentenceLength", v)}
+ options={[
+ { value: "short", label: "Short" },
+ { value: "medium", label: "Medium" },
+ { value: "long", label: "Long" },
+ ]}
+ />
+
+
+ set("neverSay", profile.neverSay.filter((t) => t !== item))}
+ onAdd={(item) => set("neverSay", [...profile.neverSay, item])}
+ />
+
+ {showAlwaysInclude ? (
+ set("alwaysInclude", profile.alwaysInclude.filter((t) => t !== item))}
+ onAdd={(item) => set("alwaysInclude", [...profile.alwaysInclude, item])}
+ />
+ ) : null}
+
+ {profile.tone.length < 3 ? (
+
+ {VOICE_TONE_HINT_COPY}
+
+ ) : null}
+
+ );
+}
+
+/** §7.4 fixes tone at exactly 3 — forms gate Save/Continue on this. */
+export function profileComplete(profile: VoiceProfile): boolean {
+ return profile.tone.length === 3;
+}
diff --git a/web/db/queries.ts b/web/db/queries.ts
index 3d893e9..5acebf1 100644
--- a/web/db/queries.ts
+++ b/web/db/queries.ts
@@ -142,6 +142,9 @@ export async function updateShow(
listenUrl: string | null;
defaultCta: string | null;
vocabulary: string[];
+ /** §7.4 profile JSON — validated with VoiceProfileSchema before it gets here. */
+ voiceProfile: unknown;
+ voiceSamples: string[];
}>,
): Promise {
const db = getDb();
diff --git a/web/lib/ai/voice.ts b/web/lib/ai/voice.ts
index 8a14daa..578052d 100644
--- a/web/lib/ai/voice.ts
+++ b/web/lib/ai/voice.ts
@@ -158,3 +158,89 @@ export async function previewVoice(args: {
}
return { ok: false, errorCode: "voice_preview_failed" };
}
+
+// ---- deterministic no-AI mode (§8.1 last resort, added in WP-07) ------------
+// U3 gives "Learn my voice" ≤15 s; with no usable provider key the chain above
+// fails in seconds and onboarding must still produce a profile. Both functions
+// below derive ONLY from the user's own input (extractive, never invented);
+// fields a lexicon-free analyzer cannot honestly infer stay at the §7.4
+// defaults. Callers must label the result as the basic version — it is never
+// presented as a learned AI profile.
+
+/** First-person pronoun counts decide person (word-boundary, contractions included). */
+const PRONOUN_RE = {
+ I: /\bI\b|\bI'm\b|\bI've\b|\bI'll\b|\bI'd\b|\bme\b|\bmy\b|\bmine\b|\bmyself\b/gi,
+ we: /\bwe\b|\bwe're\b|\bwe've\b|\bwe'll\b|\bwe'd\b|\bus\b|\bour\b|\bours\b|\bourselves\b/gi,
+ they: /\bthey\b|\bthey're\b|\bthey've\b|\bthey'll\b|\bthem\b|\btheir\b|\btheirs\b/gi,
+} as const;
+
+/** Sentence splitter without the engine's 40-char floor: short-sentence
+ * authors would otherwise look "long" because their sentences get dropped. */
+function splitSentences(text: string): string[] {
+ return text
+ .split(/(?<=[.!?])\s+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+export function learnVoiceProfileDeterministic(samples: string[]): VoiceProfile {
+ const text = samples.join(" ").replace(/\s+/g, " ").trim();
+ const count = (re: RegExp) => (text.match(re)?.length ?? 0);
+ const iCount = count(PRONOUN_RE.I);
+ const weCount = count(PRONOUN_RE.we);
+ const theyCount = count(PRONOUN_RE.they);
+ const max = Math.max(iCount, weCount, theyCount);
+ const person: VoiceProfile["person"] =
+ max === 0 ? DEFAULT_VOICE_PROFILE.person : iCount === max ? "I" : weCount === max ? "we" : "they";
+
+ const sents = splitSentences(text);
+ const words = text.split(" ").filter(Boolean);
+ const avgWords = sents.length ? words.length / sents.length : 0;
+ const sentenceLength: VoiceProfile["sentenceLength"] =
+ sents.length === 0 || (avgWords >= 12 && avgWords <= 22)
+ ? DEFAULT_VOICE_PROFILE.sentenceLength
+ : avgWords < 12
+ ? "short"
+ : "long";
+
+ const emojiCount = (text.match(/\p{Extended_Pictographic}/gu) ?? []).length;
+ const emoji: VoiceProfile["emoji"] =
+ emojiCount === 0 ? DEFAULT_VOICE_PROFILE.emoji : emojiCount <= 5 ? "light" : "heavy";
+
+ return { ...DEFAULT_VOICE_PROFILE, person, sentenceLength, emoji };
+}
+
+/**
+ * A ~60-word sample paragraph rendered from the profile's own parameters
+ * (person, tone, sentence length, emoji). Pure function of its inputs so the
+ * preview card always reflects the editor state; contains no never-say term
+ * because the template vocabulary is fixed.
+ */
+export function previewVoiceDeterministic(voice: VoiceProfile, show?: { name?: string }): string {
+ const name = show?.name?.trim() || "the show";
+ const subject = voice.person === "I" ? "I" : voice.person === "we" ? "We" : "They";
+ const poss = voice.person === "I" ? "my" : voice.person === "we" ? "our" : "their";
+ const casual = voice.formality <= 2;
+
+ const opener = `This week on ${name}: a ${voice.tone.join(", ")} look at the habits behind a calmer week.`;
+ const core =
+ voice.sentenceLength === "short"
+ ? `${subject} ${casual ? "get into" : "discuss"} ${poss} favorite routine.`
+ : `${subject} ${casual ? "get into" : "discuss"} why small routines beat big plans, and what ${poss} listeners can borrow from them.`;
+ const extra =
+ voice.sentenceLength === "short"
+ ? ""
+ : `And a quick answer to a listener question about keeping ${poss} workflow simple.`;
+ const closer =
+ voice.energy >= 4
+ ? "Bring some energy — this one moves."
+ : voice.humor >= 4
+ ? casual
+ ? "There's at least one bad joke ahead."
+ : "There is at least one bad joke ahead."
+ : casual
+ ? "Have a listen."
+ : "Enjoy the episode.";
+ const marks = voice.emoji === "heavy" ? " 🎧✨" : voice.emoji === "light" ? " 🎧" : "";
+ return `${[opener, core, extra, closer].filter(Boolean).join(" ")}${marks}`;
+}
diff --git a/web/lib/copy.ts b/web/lib/copy.ts
index c1e9c62..1c88e17 100644
--- a/web/lib/copy.ts
+++ b/web/lib/copy.ts
@@ -16,6 +16,22 @@ export const SHOW_NAME_REQUIRED_COPY = "Enter your show name.";
export const URL_INVALID_COPY = "Enter a valid URL.";
+// WP-07 — voice onboarding + settings (§7.4, MK-2, MK-8). Where the §6 deck is
+// silent these are plain second person, ≤ 12 words (01-product.md §0.4 rule 4).
+export const SAMPLE_TOO_SHORT_COPY = "Each example needs at least 150 words.";
+
+export const SAMPLE_LIMIT_COPY = "Up to three examples.";
+
+export const VOICE_PROFILE_INVALID_COPY = "That voice profile isn't valid.";
+
+/** Deterministic no-AI results are labelled, never passed off as AI (U3/U10). */
+export const VOICE_BASIC_PROFILE_COPY = "Basic profile — our AI writer was unavailable.";
+
+export const VOICE_BASIC_PREVIEW_COPY = "Basic version — our AI writer was unavailable.";
+
+/** §7.4 fixes tone at exactly 3 words; Save/Continue waits for a replacement. */
+export const VOICE_TONE_HINT_COPY = "Add a tone word.";
+
// --- WP-05: intake + ASR (§6 copy deck unless noted) ---
/** U19 — verbatim. */
diff --git a/web/lib/show/input.ts b/web/lib/show/input.ts
index fd0f8ab..b54e875 100644
--- a/web/lib/show/input.ts
+++ b/web/lib/show/input.ts
@@ -1,5 +1,14 @@
import { z } from "zod";
-import { SHOW_NAME_REQUIRED_COPY, URL_INVALID_COPY } from "@/lib/copy";
+import {
+ SAMPLE_LIMIT_COPY,
+ SAMPLE_TOO_SHORT_COPY,
+ SHOW_NAME_REQUIRED_COPY,
+ URL_INVALID_COPY,
+} from "@/lib/copy";
+import { VoiceProfileSchema } from "@/lib/ai/voice";
+import { MIN_SAMPLE_WORDS, countWords } from "@/lib/words";
+
+export { MIN_SAMPLE_WORDS, countWords };
const emptyStringToUndefined = (v: unknown) =>
typeof v === "string" && v.trim() === "" ? undefined : v;
@@ -37,8 +46,43 @@ export const showCreateSchema = z.object({
vocabulary: z.array(requiredText(60).refine((v) => v.length > 0)).max(50).optional(),
});
-/** PATCH accepts any subset of the General settings fields (M9). */
-export const showPatchSchema = showCreateSchema.partial();
+// ---- voice (M1 step 2, §7.5) -------------------------------------------------
+
+/** M1: past show notes of at least 150 words, up to 3, each ≤50k chars. */
+export const MAX_SAMPLE_CHARS = 50_000;
+
+export const voiceSampleSchema = z
+ .string()
+ .trim()
+ .min(1, SAMPLE_TOO_SHORT_COPY)
+ .max(MAX_SAMPLE_CHARS, SAMPLE_TOO_SHORT_COPY)
+ .refine((v) => countWords(v) >= MIN_SAMPLE_WORDS, SAMPLE_TOO_SHORT_COPY);
+
+function voiceSampleArray() {
+ return z
+ .array(voiceSampleSchema)
+ .min(1, SAMPLE_TOO_SHORT_COPY)
+ .max(3, SAMPLE_LIMIT_COPY);
+}
+
+/** POST /api/show/voice/learn — 1–3 past show notes (M1/U3). */
+export const voiceLearnSchema = z.object({
+ samples: voiceSampleArray(),
+});
+
+/** POST /api/show/voice/preview — profile to preview (stored one when absent). */
+export const voicePreviewSchema = z.object({
+ profile: z.unknown().optional(),
+ topic: z.string().trim().max(200).optional(),
+});
+
+/** PATCH accepts any subset of the General settings fields (M9), plus the
+ * §7.4 voice profile / samples the Voice settings page and onboarding step 2
+ * save (profile validated by the shared WP-04 schema). */
+export const showPatchSchema = showCreateSchema.partial().extend({
+ voiceProfile: VoiceProfileSchema.optional(),
+ voiceSamples: voiceSampleArray().optional(),
+});
export const rssPreviewSchema = z.object({
url: z.string().trim().min(1).max(2048),
diff --git a/web/lib/show/service.ts b/web/lib/show/service.ts
index 1f2f767..b25a4ee 100644
--- a/web/lib/show/service.ts
+++ b/web/lib/show/service.ts
@@ -77,6 +77,8 @@ export async function handleShowPatch(userId: string, raw: unknown): Promise {
+ const parsed = voiceLearnSchema.safeParse(raw);
+ if (!parsed.success) {
+ return { status: 400, json: { error: firstErrorMessage(parsed.error) } };
+ }
+ const samples = parsed.data.samples;
+
+ const db = isDbConfigured();
+ const show = db ? await queries.getShowByOwner(userId) : null;
+ if (db && !show) {
+ return { status: 404, json: { error: NO_SHOW_COPY } };
+ }
+
+ const result = await learnVoiceProfile({
+ samples,
+ deps: deps.chain?.length
+ ? { chain: deps.chain }
+ : { selection: await resolveSelection(), timeoutMs: VOICE_TIMEOUT_MS },
+ });
+
+ let profile: VoiceProfile;
+ let learnStatus: "learned" | "deterministic";
+ let provider: string | null;
+ let model: string | null;
+ if (result.ok) {
+ profile = result.profile;
+ learnStatus = "learned";
+ provider = result.provider;
+ model = result.model;
+ } else {
+ profile = learnVoiceProfileDeterministic(samples);
+ learnStatus = "deterministic";
+ provider = null;
+ model = null;
+ }
+
+ let saved = false;
+ if (show) {
+ await queries.updateShow(show.id, { voiceProfile: profile, voiceSamples: samples });
+ saved = true;
+ }
+
+ return { status: 200, json: { status: learnStatus, profile, provider, model, saved } };
+}
+
+/**
+ * POST /api/show/voice/preview (M9/MK-8): write one short sample paragraph in
+ * the voice. The profile may be posted (live editor state) or falls back to
+ * the stored one. Deterministic text is labelled so the UI can say the AI
+ * writer was unavailable instead of faking a model answer.
+ */
+export async function handleVoicePreview(
+ userId: string,
+ raw: unknown,
+ deps: ShowVoiceDeps = {},
+): Promise {
+ const parsed = voicePreviewSchema.safeParse(raw);
+ if (!parsed.success) {
+ return { status: 400, json: { error: firstErrorMessage(parsed.error) } };
+ }
+
+ const db = isDbConfigured();
+ const show = db ? await queries.getShowByOwner(userId) : null;
+
+ let voice: VoiceProfile;
+ if (parsed.data.profile !== undefined) {
+ const fromRequest = parseVoiceProfile(parsed.data.profile);
+ if (!fromRequest) {
+ return { status: 400, json: { error: VOICE_PROFILE_INVALID_COPY } };
+ }
+ voice = fromRequest;
+ } else {
+ voice = resolveShowVoiceProfile(show);
+ }
+
+ const showContext = { name: show?.name ?? "the show" };
+ const result = await previewVoice({
+ voice,
+ show: showContext,
+ topic: parsed.data.topic,
+ deps: deps.chain?.length
+ ? { chain: deps.chain }
+ : { selection: await resolveSelection(), timeoutMs: VOICE_TIMEOUT_MS },
+ });
+
+ if (result.ok) {
+ return {
+ status: 200,
+ json: { status: "ai", text: result.text, provider: result.provider, model: result.model },
+ };
+ }
+ return {
+ status: 200,
+ json: {
+ status: "deterministic",
+ text: previewVoiceDeterministic(voice, showContext),
+ provider: null,
+ model: null,
+ },
+ };
+}
diff --git a/web/lib/words.ts b/web/lib/words.ts
new file mode 100644
index 0000000..02240e0
--- /dev/null
+++ b/web/lib/words.ts
@@ -0,0 +1,10 @@
+// Client-safe word helpers (no lib/ai imports — countWords is used in browser
+// bundles by the voice forms; the zod schemas in lib/show/input reuse it).
+
+/** Whitespace-split word count, matching the onboarding counter. */
+export function countWords(text: string): number {
+ return text.trim().split(/\s+/).filter(Boolean).length;
+}
+
+/** M1: past show notes have at least 150 words (1–3 samples, §7.5). */
+export const MIN_SAMPLE_WORDS = 150;
diff --git a/web/tests/e2e/onboarding.spec.ts b/web/tests/e2e/onboarding.spec.ts
index eeb32a6..29ebe6e 100644
--- a/web/tests/e2e/onboarding.spec.ts
+++ b/web/tests/e2e/onboarding.spec.ts
@@ -54,9 +54,11 @@ test.describe("WP-02 keyless shells", () => {
});
});
- test("step 2 placeholder offers skip to step 3", async ({ page }) => {
+ test("step 2 renders the voice form; skip lands on step 3 (WP-07 shell)", async ({ page }) => {
await page.goto("/app/onboarding/voice");
- await expect(page.getByText("This page is coming soon.")).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Teach EpisodeKit your voice" })).toBeVisible();
+ await expect(page.getByTestId("sample-counter-0")).toHaveText("0 words · at least 150");
+ await expect(page.getByTestId("learn-voice")).toBeDisabled();
await page.getByTestId("skip-voice").click();
await expect(page.getByRole("heading", { name: "Add your first episode" })).toBeVisible();
});
diff --git a/web/tests/e2e/settings.spec.ts b/web/tests/e2e/settings.spec.ts
index 265940d..c2f74a3 100644
--- a/web/tests/e2e/settings.spec.ts
+++ b/web/tests/e2e/settings.spec.ts
@@ -35,9 +35,7 @@ test.describe("WP-02 settings and app shells", () => {
);
});
- test("settings tabs exist; Voice/Account are stubs and Billing is real (WP-08)", async ({ page }) => {
- await page.goto("/app/settings/voice");
- await expect(page.getByTestId("voice-stub")).toBeVisible();
+ test("settings tabs exist; Voice and Billing are real; Account is a stub (WP-07/WP-08)", async ({ page }) => {
await page.goto("/app/settings/billing");
await expect(page.getByTestId("billing-plan-card")).toBeVisible();
await page.goto("/app/settings/account");
diff --git a/web/tests/e2e/voice.spec.ts b/web/tests/e2e/voice.spec.ts
new file mode 100644
index 0000000..80b025a
--- /dev/null
+++ b/web/tests/e2e/voice.spec.ts
@@ -0,0 +1,138 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { expect, test } from "@playwright/test";
+
+/**
+ * WP-07 e2e — voice onboarding step 2 (U3, MK-2) and Settings → Voice
+ * (M9, MK-8), keyless: no AI keys and no DATABASE_URL, so "Learn my voice"
+ * runs the deterministic lib/ai path and answers are labelled; saving reports
+ * the preview copy instead of faking a write.
+ */
+// Playwright runs from web/ (playwright.config.ts location).
+const voiceSample = readFileSync(join(process.cwd(), "tests/fixtures/voice-sample.txt"), "utf8");
+
+test.describe("WP-07 onboarding step 2 (U3)", () => {
+ test("learn flow: paste sample → Learn → chips/sliders → Continue → step 3", async ({ page }) => {
+ await page.goto("/app/onboarding/voice");
+ await page.getByLabel("Example 1").fill(voiceSample);
+ await expect(page.getByTestId("sample-counter-0")).toContainText("170 words · at least 150");
+ await expect(page.getByTestId("learn-voice")).toBeEnabled();
+
+ await page.getByTestId("learn-voice").click();
+
+ // U3: chips + sliders appear within 15 s (deterministic path is instant here).
+ await expect(page.getByRole("heading", { name: "Here's your voice" })).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(page.getByTestId("tone-chips")).toBeVisible();
+ // Deterministic learn keeps the §7.4 tone trio; person is derived from the fixture ("I").
+ await expect(page.getByTestId("tone-chip-clear")).toBeVisible();
+ await expect(page.getByTestId("tone-chip-warm")).toBeVisible();
+ await expect(page.getByTestId("tone-chip-curious")).toBeVisible();
+ await expect(page.getByTestId("slider-formality")).toBeVisible();
+ await expect(page.getByTestId("slider-energy")).toBeVisible();
+ await expect(page.getByTestId("slider-humor")).toBeVisible();
+ await expect(page.getByTestId("seg-person-I")).toHaveAttribute("aria-checked", "true");
+ // Honest labelling: no AI keys exist here, so this was NOT a learned AI profile.
+ await expect(page.getByTestId("voice-note")).toContainText("Basic profile");
+ await expect(page.getByTestId("voice-saved-note")).toContainText(
+ "Saving is unavailable in this preview.",
+ );
+ // MK-2 shows a preview in the after-learning card, rendered from the profile.
+ await expect(page.getByTestId("preview-text")).toBeVisible({ timeout: 15_000 });
+
+ await page.getByTestId("continue-voice").click();
+ await expect(page.getByRole("heading", { name: "Add your first episode" })).toBeVisible();
+ });
+
+ test("an under-150-word sample keeps Learn disabled with the hint", async ({ page }) => {
+ await page.goto("/app/onboarding/voice");
+ await page.getByLabel("Example 1").fill("Too short by far.");
+ await expect(page.getByTestId("sample-counter-0")).toContainText("4 words · at least 150");
+ await expect(page.getByTestId("learn-voice")).toBeDisabled();
+ await expect(page.getByTestId("voice-min-hint")).toHaveText(
+ "Each example needs at least 150 words.",
+ );
+ });
+
+ test("a second example can be added up to three", async ({ page }) => {
+ await page.goto("/app/onboarding/voice");
+ await expect(page.getByLabel("Example 1")).toBeVisible();
+ await page.getByTestId("add-sample-0").click();
+ await expect(page.getByLabel("Example 2")).toBeVisible();
+ await page.getByTestId("add-sample-1").click();
+ await expect(page.getByLabel("Example 3")).toBeVisible();
+ await expect(page.getByTestId("add-sample-2")).toHaveCount(0);
+ });
+
+ test("tone chips are removable and the editor gates Continue on three tones", async ({ page }) => {
+ await page.goto("/app/onboarding/voice");
+ await page.getByLabel("Example 1").fill(voiceSample);
+ await page.getByTestId("learn-voice").click();
+ await expect(page.getByTestId("tone-chips")).toBeVisible({ timeout: 15_000 });
+
+ await page.getByTestId("tone-remove-clear").click();
+ await expect(page.getByTestId("tone-hint")).toHaveText("Add a tone word.");
+ await expect(page.getByTestId("continue-voice")).toBeDisabled();
+ await page.getByTestId("tone-add").click();
+ await page.getByLabel("New tone term").fill("wry");
+ await page.getByLabel("New tone term").press("Enter");
+ await expect(page.getByTestId("tone-chip-wry")).toBeVisible();
+ await expect(page.getByTestId("tone-hint")).toHaveCount(0);
+ await expect(page.getByTestId("continue-voice")).toBeEnabled();
+ });
+
+ test("skip for now lands on step 3 without learning", async ({ page }) => {
+ await page.goto("/app/onboarding/voice");
+ await page.getByTestId("skip-voice").click();
+ await expect(page.getByRole("heading", { name: "Add your first episode" })).toBeVisible();
+ });
+});
+
+test.describe("WP-07 settings → Voice (M9, MK-8)", () => {
+ test("default §7.4 profile renders keyless with preview and samples block", async ({ page }) => {
+ await page.goto("/app/settings/voice");
+ await expect(page.getByTestId("tone-chip-clear")).toBeVisible();
+ await expect(page.getByTestId("tone-chip-warm")).toBeVisible();
+ await expect(page.getByTestId("tone-chip-curious")).toBeVisible();
+ await expect(page.getByTestId("slider-humor")).toBeVisible();
+ await expect(page.getByTestId("seg-person-we")).toHaveAttribute("aria-checked", "true");
+ await expect(page.getByTestId("seg-emoji-none")).toHaveAttribute("aria-checked", "true");
+ await expect(page.getByTestId("seg-sentencelength-medium")).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ await expect(page.getByTestId("never-say-chip-delve")).toBeVisible();
+ await expect(page.getByTestId("always-include-chips")).toBeVisible();
+ await expect(page.getByTestId("voice-samples")).toBeVisible();
+
+ // Preview renders from the profile (deterministic keyless) and is labelled.
+ await expect(page.getByTestId("preview-text")).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByTestId("preview-note")).toContainText("Basic version");
+ });
+
+ test("Update preview re-renders; Save keyless shows the preview copy", async ({ page }) => {
+ await page.goto("/app/settings/voice");
+ await expect(page.getByTestId("preview-text")).toBeVisible({ timeout: 15_000 });
+ await page.getByTestId("preview-update").click();
+ await expect(page.getByTestId("preview-text")).toBeVisible();
+
+ await page.getByTestId("save-voice").click();
+ await expect(page.getByTestId("voice-error")).toHaveText(
+ "Saving is unavailable in this preview.",
+ );
+ });
+
+ test("adding a sample requires 150 words; remove works", async ({ page }) => {
+ await page.goto("/app/settings/voice");
+ await page.getByTestId("add-sample").click();
+ await page.getByTestId("new-voice-sample").fill("Not nearly long enough yet.");
+ await expect(page.getByTestId("confirm-add-sample")).toBeDisabled();
+ await page.getByTestId("new-voice-sample").fill(voiceSample);
+ await expect(page.getByTestId("confirm-add-sample")).toBeEnabled();
+ await page.getByTestId("confirm-add-sample").click();
+ await expect(page.getByTestId("voice-sample-row-0")).toContainText("170 words");
+ await page.getByTestId("remove-sample-0").click();
+ await expect(page.getByTestId("voice-sample-row-0")).toHaveCount(0);
+ });
+});
diff --git a/web/tests/fixtures/voice-sample.txt b/web/tests/fixtures/voice-sample.txt
new file mode 100644
index 0000000..ef22d90
--- /dev/null
+++ b/web/tests/fixtures/voice-sample.txt
@@ -0,0 +1,5 @@
+This week I look at the small habits that keep a recording week calm. I talk about why I plan episodes in batches, and how I turn one rough idea into a clear outline before I ever press record. I also share the checklist I run the night before a session, from checking levels to closing every tab that wants my attention.
+
+Later in the episode I walk through the way I review an outline, cut the sections that do not help the listener, and leave room for the parts that do. I keep the gear list short on purpose. The tools matter less than the routine, and the routine fits on one page. If your week feels loud, this episode is a quiet place to start.
+
+I close with one question to ask yourself before your next recording, and a simple way to keep the answer visible all week. It is the same question I ask myself every Sunday evening, and it takes less than a minute to answer.
diff --git a/web/tests/unit/show-voice.test.ts b/web/tests/unit/show-voice.test.ts
new file mode 100644
index 0000000..8fff1da
--- /dev/null
+++ b/web/tests/unit/show-voice.test.ts
@@ -0,0 +1,348 @@
+// WP-07 — voice onboarding + settings: §7.4 default exactness, deterministic
+// no-key learn path, sample word-count validation, and route boundary
+// behaviour (DB and providers mocked at the boundary, never faked inline).
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { readFileSync } from "node:fs";
+import {
+ DEFAULT_VOICE_PROFILE,
+ VoiceProfileSchema,
+ learnVoiceProfileDeterministic,
+ previewVoiceDeterministic,
+ type VoiceProfile,
+} from "@/lib/ai/voice";
+import { handleVoiceLearn, handleVoicePreview, resolveShowVoiceProfile } from "@/lib/show/voice";
+import { showPatchSchema, voiceLearnSchema } from "@/lib/show/input";
+import { SAMPLE_TOO_SHORT_COPY, VOICE_PROFILE_INVALID_COPY } from "@/lib/copy";
+import { fakeProvider, failingProvider } from "./helpers/ai";
+
+vi.mock("@/db/queries", () => ({
+ getShowByOwner: vi.fn(),
+ updateShow: vi.fn(),
+}));
+
+import * as queries from "@/db/queries";
+
+const mocked = vi.mocked(queries);
+
+const voiceSample = readFileSync(new URL("../fixtures/voice-sample.txt", import.meta.url), "utf8");
+
+const showRow = {
+ id: "show-1",
+ workspaceId: "ws-1",
+ name: "The Focus Hour",
+ rssUrl: null,
+ artworkUrl: null,
+ listenUrl: null,
+ defaultCta: null,
+ vocabulary: [],
+ voiceProfile: null,
+ voiceSamples: [],
+ createdAt: new Date(),
+};
+
+/** A learned profile that differs from the default in every derived field. */
+const LEARNED: VoiceProfile = {
+ tone: ["dry", "direct", "curious"],
+ formality: 2,
+ energy: 4,
+ humor: 3,
+ person: "I",
+ sentenceLength: "short",
+ emoji: "light",
+ signaturePhrases: ["here's the thing"],
+ neverSay: ["synergy"],
+ alwaysInclude: ["follow the show"],
+ ctaStyle: "Ask for one reply.",
+};
+
+afterEach(() => {
+ delete process.env.DATABASE_URL;
+ vi.clearAllMocks();
+});
+
+describe("§7.4 default profile — byte-for-byte (Skip path)", () => {
+ it("the default constant equals the spec literal field for field", () => {
+ expect(DEFAULT_VOICE_PROFILE).toEqual({
+ tone: ["clear", "warm", "curious"],
+ formality: 3,
+ energy: 3,
+ humor: 2,
+ person: "we",
+ sentenceLength: "medium",
+ emoji: "none",
+ signaturePhrases: [],
+ neverSay: ["delve", "game-changer", "unlock", "dive deep", "in today's fast-paced world"],
+ alwaysInclude: [],
+ ctaStyle: "Invite listeners to follow the show.",
+ });
+ });
+
+ it("a null show row and a null stored profile both resolve to the exact default", () => {
+ expect(resolveShowVoiceProfile(null)).toEqual(DEFAULT_VOICE_PROFILE);
+ expect(resolveShowVoiceProfile(showRow)).toEqual(DEFAULT_VOICE_PROFILE);
+ expect(resolveShowVoiceProfile({ voiceProfile: null })).toEqual(DEFAULT_VOICE_PROFILE);
+ });
+
+ it("a corrupt stored profile falls back to the exact default (never crashes)", () => {
+ expect(resolveShowVoiceProfile({ voiceProfile: { tone: ["nope"], formality: 99 } })).toEqual(
+ DEFAULT_VOICE_PROFILE,
+ );
+ });
+
+ it("a stored valid profile is returned as-is", () => {
+ expect(resolveShowVoiceProfile({ voiceProfile: LEARNED })).toEqual(LEARNED);
+ });
+
+ it("Skip persists nothing (no updateShow call happens on the skip path)", async () => {
+ // Skip is a client-side navigation; the default is resolved at read time.
+ // This pins the contract the UI relies on: resolve(null) === default.
+ expect(mocked.updateShow).not.toHaveBeenCalled();
+ expect(resolveShowVoiceProfile(null).tone).toEqual(["clear", "warm", "curious"]);
+ });
+});
+
+describe("deterministic learn (no-key path)", () => {
+ it("keeps every underivable field at the §7.4 default and stays schema-valid", () => {
+ const profile = learnVoiceProfileDeterministic([voiceSample]);
+ expect(VoiceProfileSchema.safeParse(profile).success).toBe(true);
+ expect(profile.tone).toEqual(DEFAULT_VOICE_PROFILE.tone);
+ expect(profile.formality).toBe(DEFAULT_VOICE_PROFILE.formality);
+ expect(profile.energy).toBe(DEFAULT_VOICE_PROFILE.energy);
+ expect(profile.humor).toBe(DEFAULT_VOICE_PROFILE.humor);
+ expect(profile.signaturePhrases).toEqual(DEFAULT_VOICE_PROFILE.signaturePhrases);
+ expect(profile.neverSay).toEqual(DEFAULT_VOICE_PROFILE.neverSay);
+ expect(profile.alwaysInclude).toEqual(DEFAULT_VOICE_PROFILE.alwaysInclude);
+ expect(profile.ctaStyle).toBe(DEFAULT_VOICE_PROFILE.ctaStyle);
+ });
+
+ it("derives person from first-person pronouns in the samples", () => {
+ const first = learnVoiceProfileDeterministic([
+ "I plan my week on Sunday. My notes are short. I record alone and I edit the same day.",
+ ]);
+ expect(first.person).toBe("I");
+
+ const together = learnVoiceProfileDeterministic([
+ "We plan our week together. Our notes keep us honest. We record on Mondays and we ship the same day.",
+ ]);
+ expect(together.person).toBe("we");
+
+ const third = learnVoiceProfileDeterministic([
+ "They plan their week early. Their notes stay short. They record on Mondays and they ship the same day.",
+ ]);
+ expect(third.person).toBe("they");
+ });
+
+ it("derives sentence length from the average sentence", () => {
+ const short = learnVoiceProfileDeterministic([
+ "Short sentences. Very short. They land. Every time. No filler. Cut it. Then cut again. Ship it now.",
+ ]);
+ expect(short.sentenceLength).toBe("short");
+
+ const long = learnVoiceProfileDeterministic([
+ "When you sit down on a Sunday evening and look at the week ahead, the plan you wrote on Friday morning can feel like it was left there by a stranger who had far more energy and far fewer meetings than the person reading it now, and that gap is exactly where most plans quietly die.",
+ ]);
+ expect(long.sentenceLength).toBe("long");
+ });
+
+ it("derives emoji usage from the sample text", () => {
+ const plain = learnVoiceProfileDeterministic([voiceSample]);
+ expect(plain.emoji).toBe("none");
+ const light = learnVoiceProfileDeterministic([
+ `${voiceSample} Great episode today 🎧 big thanks for listening ✨`.repeat(1),
+ ]);
+ expect(light.emoji).toBe("light");
+ });
+
+ it("is deterministic: the same samples give the same profile", () => {
+ expect(learnVoiceProfileDeterministic([voiceSample])).toEqual(
+ learnVoiceProfileDeterministic([voiceSample]),
+ );
+ });
+});
+
+describe("sample validation (§M1: 1–3 past show notes, ≥150 words each)", () => {
+ const words = (n: number) => Array.from({ length: n }, (_, i) => `word${i}`).join(" ");
+
+ it("accepts three samples of 150+ words", () => {
+ const parsed = voiceLearnSchema.safeParse({ samples: [words(150), words(160), words(200)] });
+ expect(parsed.success).toBe(true);
+ });
+
+ it("rejects a sample under 150 words with the friendly copy", () => {
+ const parsed = voiceLearnSchema.safeParse({ samples: [words(149)] });
+ expect(parsed.success).toBe(false);
+ if (!parsed.success) expect(parsed.error.issues[0]?.message).toBe(SAMPLE_TOO_SHORT_COPY);
+ });
+
+ it("rejects more than three samples and an empty body", () => {
+ expect(voiceLearnSchema.safeParse({ samples: [words(150), words(150), words(150), words(150)] }).success).toBe(false);
+ expect(voiceLearnSchema.safeParse({ samples: [] }).success).toBe(false);
+ expect(voiceLearnSchema.safeParse({}).success).toBe(false);
+ });
+});
+
+describe("handleVoiceLearn (POST /api/show/voice/learn)", () => {
+ it("400 with friendly copy when a sample is too short", async () => {
+ const result = await handleVoiceLearn("u1", { samples: ["too short"] });
+ expect(result.status).toBe(400);
+ expect(result.json).toEqual({ error: SAMPLE_TOO_SHORT_COPY });
+ expect(mocked.getShowByOwner).not.toHaveBeenCalled();
+ });
+
+ it("provider success persists the learned profile + samples (saved: true)", async () => {
+ process.env.DATABASE_URL = "postgres://test";
+ mocked.getShowByOwner.mockResolvedValue(showRow);
+ mocked.updateShow.mockResolvedValue(showRow);
+ const provider = fakeProvider("groq", [JSON.stringify(LEARNED)]);
+
+ const result = await handleVoiceLearn(
+ "u1",
+ { samples: [voiceSample] },
+ { chain: [provider] },
+ );
+
+ expect(result.status).toBe(200);
+ expect(result.json).toMatchObject({ status: "learned", profile: LEARNED, saved: true });
+ expect(mocked.updateShow).toHaveBeenCalledWith("show-1", {
+ voiceProfile: LEARNED,
+ voiceSamples: [voiceSample.trim()],
+ });
+ });
+
+ it("every provider failing yields the deterministic profile, honestly labelled (U3, no fake learn)", async () => {
+ process.env.DATABASE_URL = "postgres://test";
+ mocked.getShowByOwner.mockResolvedValue(showRow);
+ mocked.updateShow.mockResolvedValue(showRow);
+
+ const result = await handleVoiceLearn(
+ "u1",
+ { samples: [voiceSample] },
+ { chain: [failingProvider("glm-coding"), failingProvider("groq")] },
+ );
+
+ expect(result.status).toBe(200);
+ expect(result.json).toMatchObject({
+ status: "deterministic",
+ provider: null,
+ model: null,
+ saved: true,
+ });
+ const profile = (result.json as { profile: VoiceProfile }).profile;
+ expect(VoiceProfileSchema.safeParse(profile).success).toBe(true);
+ expect(profile.person).toBe("I"); // derived from the fixture, not defaulted silently
+ expect(mocked.updateShow).toHaveBeenCalled();
+ });
+
+ it("404 when the DB is configured but no show exists yet", async () => {
+ process.env.DATABASE_URL = "postgres://test";
+ mocked.getShowByOwner.mockResolvedValue(null);
+ const result = await handleVoiceLearn(
+ "u1",
+ { samples: [voiceSample] },
+ { chain: [failingProvider("groq")] },
+ );
+ expect(result.status).toBe(404);
+ expect(mocked.updateShow).not.toHaveBeenCalled();
+ });
+
+ it("keyless preview: learn still runs (deterministic) but reports saved: false and writes nothing", async () => {
+ const result = await handleVoiceLearn(
+ "u1",
+ { samples: [voiceSample] },
+ { chain: [failingProvider("glm-coding")] },
+ );
+ expect(result.status).toBe(200);
+ expect(result.json).toMatchObject({ status: "deterministic", saved: false });
+ expect(mocked.getShowByOwner).not.toHaveBeenCalled();
+ expect(mocked.updateShow).not.toHaveBeenCalled();
+ });
+});
+
+describe("handleVoicePreview (POST /api/show/voice/preview)", () => {
+ it("returns the AI text when a provider answers", async () => {
+ const provider = fakeProvider("groq", ["Here is your sample paragraph in your voice."]);
+ const result = await handleVoicePreview("u1", { profile: LEARNED }, { chain: [provider] });
+ expect(result.status).toBe(200);
+ expect(result.json).toMatchObject({ status: "ai", provider: "groq" });
+ expect(result.json.text).toContain("sample paragraph");
+ });
+
+ it("falls back to the deterministic preview text (labelled) when providers fail", async () => {
+ const result = await handleVoicePreview(
+ "u1",
+ { profile: LEARNED },
+ { chain: [failingProvider("glm-coding")] },
+ );
+ expect(result.status).toBe(200);
+ expect(result.json).toMatchObject({ status: "deterministic", provider: null });
+ expect(typeof result.json.text).toBe("string");
+ expect(result.json.text).not.toBe("");
+ });
+
+ it("uses the stored profile when none is sent", async () => {
+ process.env.DATABASE_URL = "postgres://test";
+ mocked.getShowByOwner.mockResolvedValue({ ...showRow, voiceProfile: LEARNED });
+ const provider = fakeProvider("groq", ["A fresh preview."]);
+ const result = await handleVoicePreview("u1", {}, { chain: [provider] });
+ expect(result.json).toMatchObject({ status: "ai", text: "A fresh preview." });
+ });
+
+ it("rejects an invalid profile with friendly copy", async () => {
+ const result = await handleVoicePreview("u1", { profile: { formality: 42 } });
+ expect(result.status).toBe(400);
+ expect(result.json).toEqual({ error: VOICE_PROFILE_INVALID_COPY });
+ });
+});
+
+describe("deterministic preview (renders from the profile)", () => {
+ it("is a plain-text sample whose content tracks the profile", () => {
+ const a = previewVoiceDeterministic(LEARNED, { name: "The Focus Hour" });
+ expect(a).not.toMatch(/[{}]/); // plain text, not JSON
+ expect(a.split(/\s+/).filter(Boolean).length).toBeGreaterThan(25);
+ expect(a.split(/\s+/).filter(Boolean).length).toBeLessThan(90);
+ expect(a).toContain("The Focus Hour");
+ expect(a).not.toMatch(/\s{2,}/); // no double spaces from template joins
+ // person pronoun follows the profile
+ expect(a).toContain("I ");
+ });
+
+ it("changes when the profile changes", () => {
+ const a = previewVoiceDeterministic(LEARNED, { name: "The Focus Hour" });
+ const b = previewVoiceDeterministic(
+ { ...LEARNED, person: "we", emoji: "heavy", sentenceLength: "long" },
+ { name: "The Focus Hour" },
+ );
+ expect(a).not.toBe(b);
+ });
+
+ it("never contains a never-say word", () => {
+ const banned = LEARNED.neverSay[0];
+ const text = previewVoiceDeterministic({ ...LEARNED, neverSay: [banned] }, { name: "X" });
+ expect(text.toLowerCase()).not.toContain(banned.toLowerCase());
+ });
+
+ it("is stable for the same inputs", () => {
+ expect(previewVoiceDeterministic(LEARNED, { name: "X" })).toBe(
+ previewVoiceDeterministic(LEARNED, { name: "X" }),
+ );
+ });
+});
+
+describe("PATCH /api/show voice fields (settings Save voice, step-2 Continue)", () => {
+ it("accepts a valid voiceProfile and voiceSamples", () => {
+ const parsed = showPatchSchema.safeParse({
+ voiceProfile: LEARNED,
+ voiceSamples: [voiceSample],
+ });
+ expect(parsed.success).toBe(true);
+ if (parsed.success) {
+ expect(parsed.data.voiceProfile).toEqual(LEARNED);
+ expect(parsed.data.voiceSamples).toEqual([voiceSample.trim()]);
+ }
+ });
+
+ it("rejects an invalid profile and an under-length sample", () => {
+ expect(showPatchSchema.safeParse({ voiceProfile: { formality: 9 } }).success).toBe(false);
+ expect(showPatchSchema.safeParse({ voiceSamples: ["too short"] }).success).toBe(false);
+ });
+});