- |
+ |
diff --git a/admin-ui/app/(authed)/settings/SettingsForm.tsx b/admin-ui/app/(authed)/settings/SettingsForm.tsx
index 344c93f..047612a 100644
--- a/admin-ui/app/(authed)/settings/SettingsForm.tsx
+++ b/admin-ui/app/(authed)/settings/SettingsForm.tsx
@@ -390,6 +390,11 @@ const FIELD_HELP_TEXT: Record = {
"How often a show's flavor may color an ordinary lead-in/back-announce, in minutes — shares " +
"the same one-line slot as the context patter lines above; a due context fact always wins. " +
"0 disables the show-flavor line. Accepted range: 0–1440.",
+
+ // ── Crosstalk two-voice banter (SPEC F127.4) ────────────────────────────────────────────────
+ "Crosstalk:DurationTargetSeconds":
+ "The longest a generated two-voice banter exchange may run, in seconds, before it is " +
+ "discarded and skipped rather than aired. Defaults to 25. Accepted range: 5–120.",
};
/**
diff --git a/admin-ui/app/(authed)/settings/settings-help-keys.ts b/admin-ui/app/(authed)/settings/settings-help-keys.ts
index 7753321..b14f1fc 100644
--- a/admin-ui/app/(authed)/settings/settings-help-keys.ts
+++ b/admin-ui/app/(authed)/settings/settings-help-keys.ts
@@ -83,6 +83,7 @@ export const SETTINGS_HELP_KEYS = [
"Station:Imaging:TimeAnnouncements",
"Station:Imaging:TimeAnnouncementStaleMinutes",
"Station:Shows:PatterCadenceMinutes",
+ "Crosstalk:DurationTargetSeconds",
] as const;
export type SettingsHelpKey = (typeof SETTINGS_HELP_KEYS)[number];
diff --git a/admin-ui/lib/llm-calls-api.ts b/admin-ui/lib/llm-calls-api.ts
index b5eec7f..a2959cc 100644
--- a/admin-ui/lib/llm-calls-api.ts
+++ b/admin-ui/lib/llm-calls-api.ts
@@ -22,6 +22,12 @@ export interface LlmCallEntry {
response: string | null;
promptChars: number;
responseChars: number;
+ /** gh-#385, SPEC F127.11 — which generation surface produced this call
+ * (GenWave.Tts.LlmCallKind): `"copy"` for every ordinary segment-copy call, `"crosstalk"` for a
+ * CrosstalkScriptWriter call — so an operator can tell "why was there no banter" apart from an
+ * ordinary blurb miss. Plain string, not a closed union, for the same reason `status`/`mode`
+ * are above. */
+ kind: string;
}
/**
diff --git a/compose.piper-only.yaml b/compose.piper-only.yaml
index 1105706..efda3c6 100644
--- a/compose.piper-only.yaml
+++ b/compose.piper-only.yaml
@@ -11,7 +11,7 @@
# resident in the stack, and a 4GB box (Raspberry Pi 4, small VPS) can't afford it
# * drops api's hard `depends_on: kokoro: service_healthy` (keeps db/engine), so the
# api boots without any kokoro container existing at all
-# * seeds `Tts:EngineByKind` mapping ALL SEVEN SegmentKinds -> "piper", so every on-air
+# * seeds `Tts:EngineByKind` mapping ALL EIGHT SegmentKinds -> "piper", so every on-air
# render routes straight to the piper sidecar and never touches the absent primary
#
# The default experience is untouched: this file is opt-in via -f (never auto-loaded),
@@ -83,15 +83,15 @@ services:
engine:
condition: service_healthy # convenience; the feeder retries regardless
environment:
- # All seven SegmentKinds pinned to the piper engine (SPEC F70.3, STORY-191; bumped
- # from six to seven for ContextSegment, SPEC F107.3, STORY-297, PLAN T223) — the
+ # All eight SegmentKinds pinned to the piper engine (SPEC F70.3, STORY-191; bumped
+ # from seven to eight for Crosstalk, SPEC F127.1, STORY-329, PLAN T281) — the
# allowlist's expected shape: a JSON object mapping SegmentKind names to
# "kokoro"/"piper". With this map, every on-air render (TtsSegmentSource carries
- # the kind) goes straight to Piper; kokoro is never contacted. If an eighth kind
+ # the kind) goes straight to Piper; kokoro is never contacted. If a ninth kind
# is ever added to SegmentKind, add it here too — the gh-#242 spec pins this map
# against the enum, so the build fails loudly rather than silently routing the
# new kind at a kokoro that doesn't exist.
- Tts__EngineByKind: '{"StationId":"piper","LeadIn":"piper","BackAnnounce":"piper","TimeDate":"piper","SignOff":"piper","SignOn":"piper","ContextSegment":"piper"}'
+ Tts__EngineByKind: '{"StationId":"piper","LeadIn":"piper","BackAnnounce":"piper","TimeDate":"piper","SignOff":"piper","SignOn":"piper","ContextSegment":"piper","Crosstalk":"piper"}'
# gh-#334 — enrichment halved, by the same reasoning as the kokoro and LLM removals above.
# This overlay exists because a 4GB/4-core box cannot afford the default topology, and the
# ffmpeg analyzers are the heaviest sustained load GenWave produces: base compose's 4 pins
diff --git a/src/GenWave.Abstractions/Abstractions/IPersonaPreviewWriter.cs b/src/GenWave.Abstractions/Abstractions/IPersonaPreviewWriter.cs
index b44305e..162e474 100644
--- a/src/GenWave.Abstractions/Abstractions/IPersonaPreviewWriter.cs
+++ b/src/GenWave.Abstractions/Abstractions/IPersonaPreviewWriter.cs
@@ -22,11 +22,13 @@ public interface IPersonaPreviewWriter
/// persona, a draft persona built from unsaved fields, or for the
/// neutral house scaffold — in place of whatever persona is actually active on-air.
///
- /// / requests route
- /// straight to the template rung (mirrors production's own kind-based routing — those kinds
- /// never call the LLM on-air either, so this is not a fallback). LeadIn/BackAnnounce/SignOff/
- /// SignOn requests call the LLM and never degrade: any failure (disabled endpoint, timeout,
- /// non-2xx, empty/over-length copy) yields instead of
+ /// Routing follows the same closed set production uses
+ /// (GenWave.Tts.LlmCopyWriter.IsLlmAuthored — the single source of truth for which
+ /// values ever reach the LLM): a kind that predicate reports false for
+ /// routes straight to the template rung (mirrors production's own kind-based routing — this is
+ /// not a fallback, those kinds never call the LLM on-air either), and every kind it reports true
+ /// for calls the LLM and never degrades — any failure (disabled endpoint, timeout, non-2xx,
+ /// empty/over-length copy) yields instead of
/// substituting template text.
///
Task WritePreviewAsync(
diff --git a/src/GenWave.Abstractions/Domain/PersonaPreviewResult.cs b/src/GenWave.Abstractions/Domain/PersonaPreviewResult.cs
index 3c550da..720709c 100644
--- a/src/GenWave.Abstractions/Domain/PersonaPreviewResult.cs
+++ b/src/GenWave.Abstractions/Domain/PersonaPreviewResult.cs
@@ -12,9 +12,10 @@ public abstract record PersonaPreviewResult
private PersonaPreviewResult() { }
///
- /// The rendered copy, ready to display. Carries either genuine LLM output or — for
- /// /, which never touch the
- /// LLM even on-air — the exact template text production would air for that kind.
+ /// The rendered copy, ready to display. Carries either genuine LLM output or — for whichever
+ /// values GenWave.Tts.LlmCopyWriter.IsLlmAuthored reports false
+ /// for (never touch the LLM even on-air) — the exact template text production would air for that
+ /// kind.
///
public sealed record Success(string Text) : PersonaPreviewResult;
diff --git a/src/GenWave.Abstractions/Domain/SegmentKind.cs b/src/GenWave.Abstractions/Domain/SegmentKind.cs
index f80cb39..0af2e4d 100644
--- a/src/GenWave.Abstractions/Domain/SegmentKind.cs
+++ b/src/GenWave.Abstractions/Domain/SegmentKind.cs
@@ -30,4 +30,12 @@ public enum SegmentKind
/// that actually produces a SegmentRequest of this kind is T224's, not this enum's own.
///
ContextSegment,
+
+ ///
+ /// A banter exchange in which two personas share one clip (SPEC F127.1, STORY-329) — a
+ /// mid-block-seam voice moment, superseding the gated flavor/fact lanes in any break it
+ /// airs. Additive member; the wiring that vends a SegmentRequest of this kind is
+ /// T287's, not this enum's own.
+ ///
+ Crosstalk,
}
diff --git a/src/GenWave.Host/Api/LlmCallDto.cs b/src/GenWave.Host/Api/LlmCallDto.cs
index 7612b8d..c73101a 100644
--- a/src/GenWave.Host/Api/LlmCallDto.cs
+++ b/src/GenWave.Host/Api/LlmCallDto.cs
@@ -8,7 +8,9 @@ namespace GenWave.Host.Api;
/// own remarks). / are a cheap at-a-glance size
/// for the table view; the full text is what the expandable row shows.
/// (gh-#429) is who authored the call — for a persona-less render, never an
-/// empty string.
+/// empty string. (SPEC F127.11, PLAN T282) is "copy" for every ordinary
+/// segment-copy call or "crosstalk" for a call
+/// — so an operator can tell "why was there no banter" apart from an ordinary blurb miss.
///
public sealed record LlmCallDto(
long Seq,
@@ -22,4 +24,5 @@ public sealed record LlmCallDto(
string? PromptUser,
string? Response,
int PromptChars,
- int ResponseChars);
+ int ResponseChars,
+ string Kind);
diff --git a/src/GenWave.Host/Api/LlmCallsController.cs b/src/GenWave.Host/Api/LlmCallsController.cs
index 7836288..bf246ab 100644
--- a/src/GenWave.Host/Api/LlmCallsController.cs
+++ b/src/GenWave.Host/Api/LlmCallsController.cs
@@ -45,5 +45,6 @@ public IActionResult List()
record.PromptUser,
record.Response,
(record.PromptSystem?.Length ?? 0) + (record.PromptUser?.Length ?? 0),
- record.Response?.Length ?? 0);
+ record.Response?.Length ?? 0,
+ record.Kind.ToString().ToLowerInvariant());
}
diff --git a/src/GenWave.Host/Configuration/SettingValidator.cs b/src/GenWave.Host/Configuration/SettingValidator.cs
index cf558a6..d250cf2 100644
--- a/src/GenWave.Host/Configuration/SettingValidator.cs
+++ b/src/GenWave.Host/Configuration/SettingValidator.cs
@@ -187,6 +187,14 @@ public SettingValidator(IConfiguration configuration, ThemeCatalog? themeCatalog
// a negative value would still resolve safely if it slipped through some other path.
internal const int ContextPersonaIdMin = 0;
+ // Crosstalk:DurationTargetSeconds (SPEC F127.4, STORY-326, PLAN T282) — CrosstalkOptions' own
+ // [Range(1, int.MaxValue)] (boot-enforced via ValidateDataAnnotations, the
+ // Llm:MaxCopyChars precedent); this validator adds the F53.1 settings-API-only ceiling. Floor of
+ // 5s guards a degenerate near-zero target from rejecting every exchange outright; 120s (2
+ // minutes) is comfortably past the spec'd 25s default while still bounding a fat-finger entry.
+ internal const int CrosstalkDurationTargetSecondsMin = 5;
+ internal const int CrosstalkDurationTargetSecondsMax = 120;
+
// Maps each allowlisted key to a per-key (range + type) validator. An instance method (not a
// static field) purely because the Station:Theme entry below closes over the constructor's own
// themeCatalog — every other entry is a plain static delegate exactly as before.
@@ -406,6 +414,13 @@ static Dictionary> BuildValidators(ThemeCatalog theme
// Show-flavor patter line cadence (SPEC F116.3, STORY-308, PLAN T249) — same
// "0 = off, 1440 ceiling" shape as Context:{Key}:PatterCadenceMinutes above.
["Station:Shows:PatterCadenceMinutes"] = v => IsIntInRange(v, ShowsPatterCadenceMinutesMin, ShowsPatterCadenceMinutesMax),
+
+ // Crosstalk duration-fit target (SPEC F127.4, STORY-326, PLAN T282) — floor of 5s
+ // guards a degenerate near-zero target from rejecting every exchange outright (see
+ // CrosstalkDurationTargetSecondsMin's own remarks); F53.1 adds the settings-API ceiling
+ // on top of CrosstalkOptions' own boot-enforced [Range(1, int.MaxValue)].
+ ["Crosstalk:DurationTargetSeconds"] =
+ v => IsIntInRange(v, CrosstalkDurationTargetSecondsMin, CrosstalkDurationTargetSecondsMax),
};
// ── Per-key validation ─────────────────────────────────────────────────────────────────────
@@ -959,6 +974,8 @@ var k when k.Equals("Station:Imaging:ClockAnchoredIdents", StringComparison.Ordi
=> $"Value '{value}' is not valid for '{key}'. Must be a boolean (true/false).",
var k when k.Equals("Station:Shows:PatterCadenceMinutes", StringComparison.OrdinalIgnoreCase)
=> $"Value '{value}' is not valid for '{key}'. Must be an integer between {ShowsPatterCadenceMinutesMin} and {ShowsPatterCadenceMinutesMax} (minutes); 0 disables the show-flavor line.",
+ var k when k.Equals("Crosstalk:DurationTargetSeconds", StringComparison.OrdinalIgnoreCase)
+ => $"Value '{value}' is not valid for '{key}'. Must be an integer between {CrosstalkDurationTargetSecondsMin} and {CrosstalkDurationTargetSecondsMax} (seconds).",
_ => $"Value '{value}' is not valid for '{key}'.",
};
}
diff --git a/src/GenWave.Host/Configuration/StationSettingsAllowlist.cs b/src/GenWave.Host/Configuration/StationSettingsAllowlist.cs
index 473ab3f..c94153f 100644
--- a/src/GenWave.Host/Configuration/StationSettingsAllowlist.cs
+++ b/src/GenWave.Host/Configuration/StationSettingsAllowlist.cs
@@ -392,6 +392,15 @@ public static IReadOnlyList ThemeChoices(ThemeCatalog themeCatalo
// it entirely — an opt-in feature, not a default-on one (mirrors Context:{Key}:PatterCadenceMinutes's
// own "0 = off" floor immediately above).
new("Station:Shows:PatterCadenceMinutes", SettingApplyMode.Live, SettingKind.Number, "minutes"),
+
+ // Crosstalk two-voice banter, the duration-fit knob only (SPEC F127.4, STORY-326, PLAN T282)
+ // — CrosstalkScriptWriter (GenWave.Tts) reads this fresh via IOptionsMonitor
+ // on every generation attempt, so a PUT here reaches the very next attempt with no api
+ // restart. Defaults to the spec'd 25s; an estimate over target discards the WHOLE exchange
+ // rather than trimming a line (F127.4 — a cut dialogue line breaks the reaction to it).
+ // Crosstalk:Shows/EveryNthAiring (F127.8's scope/cadence pair) join this allowlist in a LATER
+ // task (T284's CrosstalkPlanner) — nothing reads them yet.
+ new("Crosstalk:DurationTargetSeconds", SettingApplyMode.Live, SettingKind.Number, "seconds"),
};
/// All operator-editable settings, keyed by configuration key.
diff --git a/src/GenWave.Host/appsettings.json b/src/GenWave.Host/appsettings.json
index d229292..51bb889 100644
--- a/src/GenWave.Host/appsettings.json
+++ b/src/GenWave.Host/appsettings.json
@@ -50,6 +50,9 @@
"Tts": {
"BlurbRetentionHours": 24
},
+ "Crosstalk": {
+ "DurationTargetSeconds": 25
+ },
"DependencyHealth": {
"ProbeIntervalSeconds": 30,
"ProbeTimeoutSeconds": 10,
diff --git a/src/GenWave.Tts/ChatCompletionChoice.cs b/src/GenWave.Tts/ChatCompletionChoice.cs
index 72d502b..9fd3ca8 100644
--- a/src/GenWave.Tts/ChatCompletionChoice.cs
+++ b/src/GenWave.Tts/ChatCompletionChoice.cs
@@ -2,5 +2,17 @@ namespace GenWave.Tts;
using System.Text.Json.Serialization;
-/// One completion choice in a (SPEC F34.3 wire shape).
-sealed record ChatCompletionChoice([property: JsonPropertyName("message")] ChatCompletionMessage? Message);
+///
+/// One completion choice in a (SPEC F34.3 wire shape).
+/// (SPEC F127.4, F127.11, PLAN T282 — the OpenAI/ollama-compatible
+/// finish_reason field, e.g. "stop"/"length") is read only by
+/// , which discards the whole exchange when it is
+/// "length" — a completion cut short by max_tokens leaves a truncated last line that
+/// can still PARSE cleanly and would otherwise air mid-word (the gh-#424 class, one seam over).
+/// when the wire omits it (an endpoint that predates this field). Purely
+/// additive: never reads this property, so its own byte-identical
+/// deserialization/behavior is unaffected by this field's addition.
+///
+sealed record ChatCompletionChoice(
+ [property: JsonPropertyName("message")] ChatCompletionMessage? Message,
+ [property: JsonPropertyName("finish_reason")] string? FinishReason = null);
diff --git a/src/GenWave.Tts/ChatCompletionResponse.cs b/src/GenWave.Tts/ChatCompletionResponse.cs
index f46e7c1..eab5b28 100644
--- a/src/GenWave.Tts/ChatCompletionResponse.cs
+++ b/src/GenWave.Tts/ChatCompletionResponse.cs
@@ -3,9 +3,12 @@ namespace GenWave.Tts;
using System.Text.Json.Serialization;
///
-/// Wire shape of an OpenAI-compatible POST /v1/chat/completions response (SPEC F34.3) —
-/// only the fields needs. Internal — callers only ever see the
-/// extracted, cleaned copy via .
+/// Wire shape of an OpenAI-compatible POST /v1/chat/completions response (SPEC F34.3),
+/// shared by and (as of PLAN T282, SPEC F127.3)
+/// — only the fields either one needs, see 's own
+/// remarks for exactly which class reads which field. Internal — callers only ever see the
+/// extracted, cleaned copy via (or, for
+/// crosstalk, a validated ).
///
sealed record ChatCompletionResponse(
[property: JsonPropertyName("choices")] List? Choices);
diff --git a/src/GenWave.Tts/CrosstalkExchangeRequest.cs b/src/GenWave.Tts/CrosstalkExchangeRequest.cs
new file mode 100644
index 0000000..3b226ac
--- /dev/null
+++ b/src/GenWave.Tts/CrosstalkExchangeRequest.cs
@@ -0,0 +1,30 @@
+namespace GenWave.Tts;
+
+using GenWave.Core.Domain;
+
+///
+/// Everything needs to request one exchange (SPEC F127.3,
+/// STORY-326 AC1, AC7) — / already CAST by the
+/// caller (a LATER task's CrosstalkPlanner, SPEC F127.2 — this writer never resolves who is
+/// on either side of the booth), plus the show/daypart/time-of-day hooks the prompt is allowed to
+/// carry. / are both optional — an unnamed block or a
+/// showless station omits the corresponding prompt line entirely, mirroring
+/// 's own "invent nothing beyond what's given" discipline
+/// one seam over.
+///
+///
+/// Structurally carries no current track (SPEC F127.3, STORY-326 AC7). There is no
+/// -typed member here, by construction, the same proof
+/// Story228_RequestShoutOut's own reflection fact pins for SegmentRequest one project
+/// over: exchanges are generated ahead of air and cannot know what is actually playing when they
+/// eventually vend, so the type this writer's prompt is built from has nothing for a future edit to
+/// accidentally interpolate a track into.
+///
+///
+public sealed record CrosstalkExchangeRequest(
+ PersonaCard HostCard,
+ PersonaCard NeighborCard,
+ string StationName,
+ string? ShowName,
+ string? Daypart,
+ DateTimeOffset StationLocalNow);
diff --git a/src/GenWave.Tts/CrosstalkLine.cs b/src/GenWave.Tts/CrosstalkLine.cs
new file mode 100644
index 0000000..df916b4
--- /dev/null
+++ b/src/GenWave.Tts/CrosstalkLine.cs
@@ -0,0 +1,14 @@
+namespace GenWave.Tts;
+
+///
+/// One accepted line of a validated (SPEC F127.3, F127.4, F127.6,
+/// STORY-326) — has already cleared
+/// and the per-line char budget, and is never trimmed (F127.4: "no line is ever trimmed — a cut
+/// dialogue line breaks the reaction to it"). (SPEC F127.3, F127.6)
+/// marks a line that overlaps the TAIL of the line immediately before it rather than following it in
+/// turn order — 's one sanctioned exception to strict
+/// speaker alternation, and (a LATER task, T285's per-line render / T287's assembler) the signal to
+/// mix this line's render starting before the previous line's render has finished, rather than after
+/// it.
+///
+public sealed record CrosstalkLine(CrosstalkSpeaker Speaker, string Text, bool IsInterjection);
diff --git a/src/GenWave.Tts/CrosstalkOptions.cs b/src/GenWave.Tts/CrosstalkOptions.cs
new file mode 100644
index 0000000..d357f8a
--- /dev/null
+++ b/src/GenWave.Tts/CrosstalkOptions.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace GenWave.Tts;
+
+///
+/// Configuration for two-voice banter generation (SPEC F127.4, F127.8, STORY-326). Only the
+/// duration-fit knob lands here at PLAN T282 — Crosstalk:Shows/Crosstalk:EveryNthAiring
+/// (SPEC F127.8's scope/cadence pair) are a LATER task's own concern (T284's CrosstalkPlanner),
+/// not read by anything this project builds.
+///
+public sealed class CrosstalkOptions
+{
+ public const string Section = "Crosstalk";
+
+ ///
+ /// The spoken-duration target a validated must fit under (SPEC
+ /// F127.4) — an estimate over this rejects the WHOLE exchange (never a trim; see
+ /// 's own remarks). Defaults to the spec'd 25 seconds. Live via
+ /// , read fresh by
+ /// on every generation attempt (mirrors every other
+ /// live-adjustable leaf this project's options classes carry), so an operator PUT reaches the
+ /// very next attempt with no api restart.
+ ///
+ [Range(1, int.MaxValue)]
+ public int DurationTargetSeconds { get; set; } = 25;
+}
diff --git a/src/GenWave.Tts/CrosstalkPromptBuilder.cs b/src/GenWave.Tts/CrosstalkPromptBuilder.cs
new file mode 100644
index 0000000..4d1a4bc
--- /dev/null
+++ b/src/GenWave.Tts/CrosstalkPromptBuilder.cs
@@ -0,0 +1,106 @@
+namespace GenWave.Tts;
+
+using GenWave.Core.Domain;
+
+///
+/// Pure, stateless prompt composition for (SPEC F127.3, STORY-326)
+/// — built BESIDE , never inside it (F116's own byte-identical
+/// discipline for the ordinary break prompt: that prompt must never change at all, and there are
+/// golden byte-pins proving it). The two builders share only what is already a small, general-purpose
+/// helper ( for each card's soul/quirks,
+/// for the station-local clock line) — nothing
+/// here reaches INTO an ordinary-break prompt method, and nothing there reaches into this one.
+///
+static class CrosstalkPromptBuilder
+{
+ ///
+ /// Chars-per-word divisor for the stated word-budget instruction (mirrors
+ /// LlmPromptBuilder's own private divisor of the identical value, for the identical
+ /// reason — see 's neighboring remarks on why a
+ /// WORD estimate divides by the true average rather than padding for headroom the way a TOKEN
+ /// cap does). STATED only, exactly like the ordinary-break prompt's own word figure — the
+ /// completion request's max_tokens () is what
+ /// actually bounds generation.
+ ///
+ const int CharsPerWordDivisor = 6;
+
+ ///
+ /// Cap for /
+ /// before either reaches the prompt (T282 review finding, SPEC F127.3/F127.11): both are
+ /// operator-editable hooks with no length constraint of their own (the db show name column is
+ /// unbounded text) flowing straight into a prompt, exactly the class of field every
+ /// LlmPromptBuilder counterpart truncates first (see that class's own
+ /// BuildShowLine/BuildHandoffLine remarks). Mirrors LlmPromptBuilder's own
+ /// private MaxSoulChars value for the identical reason — duplicated, not referenced,
+ /// exactly like above (see that constant's own remarks for why
+ /// this file states its own copy rather than reaching into a private member one class over).
+ ///
+ const int MaxSoulChars = 4000;
+
+ ///
+ /// The banter scaffold plus both speakers' persona sections (SPEC F127.3). Deliberately never
+ /// mentions a track, a song, or "what's playing" — this writer's caller (a LATER task's
+ /// CrosstalkPlanner) never hands one in (see 's own
+ /// remarks), so there is nothing here for the model to be asked about.
+ ///
+ public static string BuildSystemPrompt(PersonaCard hostCard, PersonaCard neighborCard, int maxCopyChars)
+ {
+ var wordBudget = Math.Max(1, maxCopyChars / CharsPerWordDivisor);
+
+ var scaffold =
+ $"You write a short, natural-sounding radio banter exchange between two DJs sharing " +
+ $"the booth: {CrosstalkScriptParser.HostTag} (the on-air host) and " +
+ $"{CrosstalkScriptParser.NeighborTag} (a DJ dropping in). " +
+ $"Write between {CrosstalkScriptParser.MinLines} and {CrosstalkScriptParser.MaxLines} " +
+ "short lines total, alternating speakers, in this EXACT format, one line per turn, " +
+ $"nothing else before or after: \"{CrosstalkScriptParser.HostTag}: \" or " +
+ $"\"{CrosstalkScriptParser.NeighborTag}: \". " +
+ $"Across the WHOLE exchange use no more than approximately {wordBudget} words total. " +
+ "Both DJs must speak at least once. Keep each line short and conversational, no commas, " +
+ "no stage directions, no emoji, no markdown formatting. " +
+ $"To have one speaker briefly cut in over the other's line, tag that one line " +
+ $"\"{CrosstalkScriptParser.HostTag} {CrosstalkScriptParser.InterjectionMarker}: \" " +
+ $"or \"{CrosstalkScriptParser.NeighborTag} {CrosstalkScriptParser.InterjectionMarker}: " +
+ "\" instead of alternating — use this rarely, only for a genuine interruption. " +
+ "Never mention a specific song, artist, or track - neither DJ knows what is playing " +
+ "right now.";
+
+ var hostSection = LlmPromptBuilder.BuildPersonaSection(persona: null, hostCard);
+ var neighborSection = LlmPromptBuilder.BuildPersonaSection(persona: null, neighborCard);
+
+ var lines = new List { scaffold };
+ lines.Add($"{CrosstalkScriptParser.HostTag} persona:\n{hostSection ?? "(no defined persona)"}");
+ lines.Add($"{CrosstalkScriptParser.NeighborTag} persona:\n{neighborSection ?? "(no defined persona)"}");
+
+ return string.Join("\n\n", lines);
+ }
+
+ ///
+ /// Station/show/daypart/time-of-day hooks (SPEC F127.3) — every line here is OPTIONAL and omitted
+ /// entirely when the caller has nothing to say, mirroring LlmPromptBuilder.BuildShowLine's
+ /// own "invent nothing beyond what's given" discipline one seam over. No track line exists because
+ /// carries no track to build one from.
+ ///
+ public static string BuildUserContent(CrosstalkExchangeRequest request, string stationClockLine)
+ {
+ var lines = new List
+ {
+ $"Station: {request.StationName}",
+ stationClockLine,
+ };
+
+ if (request.ShowName is { Length: > 0 } showName)
+ lines.Add($"Show: {Truncate(showName, MaxSoulChars)}");
+ if (request.Daypart is { Length: > 0 } daypart)
+ lines.Add($"Time of day: {Truncate(daypart, MaxSoulChars)}");
+
+ lines.Add("Write the exchange now.");
+
+ return string.Join('\n', lines);
+ }
+
+ /// Mirrors LlmPromptBuilder's own private Truncate helper for the
+ /// identical reason 's own remarks give.
+ static string Truncate(string text, int maxChars) =>
+ text.Length <= maxChars ? text : text[..maxChars];
+}
diff --git a/src/GenWave.Tts/CrosstalkScript.cs b/src/GenWave.Tts/CrosstalkScript.cs
new file mode 100644
index 0000000..1ab15f0
--- /dev/null
+++ b/src/GenWave.Tts/CrosstalkScript.cs
@@ -0,0 +1,13 @@
+namespace GenWave.Tts;
+
+///
+/// A fully validated two-voice banter script (SPEC F127.3, F127.4, STORY-326) — the WHOLE product of
+/// one completion, never a partial/salvaged one:
+/// is 3-8 entries long, both values appear at
+/// least once, and strict alternation holds outside any line
+/// marks — is the only producer. What a LATER task (T285's
+/// per-line render, T287's ffmpeg assembler) does with this — voicing each line through its own
+/// speaker's TtsRenderContext and mixing the renders into one asset — is out of scope here;
+/// this type is only the validated intermediate the writer hands off.
+///
+public sealed record CrosstalkScript(IReadOnlyList Lines);
diff --git a/src/GenWave.Tts/CrosstalkScriptParser.cs b/src/GenWave.Tts/CrosstalkScriptParser.cs
new file mode 100644
index 0000000..bbdc71e
--- /dev/null
+++ b/src/GenWave.Tts/CrosstalkScriptParser.cs
@@ -0,0 +1,181 @@
+namespace GenWave.Tts;
+
+///
+/// Strict parse + validation for a completion reply (SPEC F127.3,
+/// F127.4, STORY-326 AC2, AC3, AC4, AC6). Fail-closed by construction: the FIRST rule a reply breaks
+/// is the one returned — no partial credit, no salvage, no template rung (F127.4's "the failure mode
+/// is skip"). // are the
+/// single source of truth for the wire format — states the exact
+/// same three tokens in the instructions it builds, so the model is never asked to emit a shape this
+/// parser doesn't also accept.
+///
+static class CrosstalkScriptParser
+{
+ /// The literal line prefix a HOST turn is tagged with. Deliberately a fixed ROLE token,
+ /// never the persona's own display name — a card whose Name contains a colon, a space, or
+ /// changes between deploys must never destabilize the parse.
+ public const string HostTag = "HOST";
+
+ /// The literal line prefix a NEIGHBOR turn is tagged with — see 's own remarks.
+ public const string NeighborTag = "NEIGHBOR";
+
+ ///
+ /// Appended to a speaker tag (before the colon) to mark a line as an interjection (SPEC F127.3,
+ /// F127.6) — e.g. "HOST (interjects): ...". Matched case-insensitively, with optional
+ /// surrounding whitespace, by .
+ ///
+ public const string InterjectionMarker = "(interjects)";
+
+ /// Fewest speaker-tagged lines a script may carry (SPEC F127.4).
+ public const int MinLines = 3;
+
+ /// Most speaker-tagged lines a script may carry (SPEC F127.4).
+ public const int MaxLines = 8;
+
+ ///
+ /// Cap for a raw model line echoed into a discard reason (T282 review finding, F127.4/F127.11):
+ /// that reason reaches an Information log line AND
+ /// (the /api/llm-calls ring), neither of which is the debug surface for a whole raw
+ /// reply — never logs a raw reply at Information either (see that
+ /// class's own LogFailure remarks: "excludes the prompt itself"). ~120 chars is enough
+ /// to show an operator WHICH line broke without echoing a whole unbounded model line into a log
+ /// line/ring entry.
+ ///
+ const int MaxEchoedLineChars = 120;
+
+ ///
+ /// Spoken-rate constant for the F127.4 duration estimate — words/characters spoken per second of
+ /// air, the SAME 15 chars/sec cold-tier heuristic GenWave.Orchestration.RollingPatterDurationEstimator
+ /// already uses for ordinary patter (duplicated, not referenced: that project cannot depend on
+ /// this one, the identical L1/L5 layering reason that estimator's own remarks give for
+ /// duplicating LlmOptions.MaxCopyChars's default rather than reading it live). Using the
+ /// SAME figure keeps "how long will this run" answered consistently across every spoken-copy
+ /// estimate in the codebase rather than two independently-tuned guesses.
+ ///
+ internal const double CharsPerSecond = 15.0;
+
+ ///
+ /// Parses and fully validates one completion reply into a (SPEC
+ /// F127.3, F127.4). is the per-line char budget (the SAME
+ /// Llm:MaxCopyChars ceiling an ordinary blurb carries — no second setting); a line over it
+ /// discards the WHOLE exchange, never a trim (F127.4). is
+ /// the live value.
+ ///
+ public static CrosstalkWriteResult Parse(string rawResponse, int maxLineChars, int durationTargetSeconds)
+ {
+ var rawLines = rawResponse
+ .Split('\n')
+ .Select(line => line.Trim())
+ .Where(line => line.Length > 0)
+ .ToList();
+
+ if (rawLines.Count is < MinLines or > MaxLines)
+ {
+ return Discarded(
+ $"expected {MinLines}-{MaxLines} speaker-tagged lines, got {rawLines.Count}");
+ }
+
+ var lines = new List(rawLines.Count);
+ foreach (var rawLine in rawLines)
+ {
+ if (!TryParseLine(rawLine, out var speaker, out var isInterjection, out var rawText))
+ {
+ return Discarded(
+ $"line does not match the '{HostTag}:'/'{NeighborTag}:' speaker-tag format: " +
+ $"\"{TruncateForEcho(rawLine)}\"");
+ }
+
+ var cleaned = LlmCopyWriter.ApplyCopyHygiene(rawText);
+ if (cleaned.Length == 0)
+ return Discarded($"a {DescribeSpeaker(speaker)} line was empty after cleanup");
+ if (cleaned.Length > maxLineChars)
+ {
+ return Discarded(
+ $"a {DescribeSpeaker(speaker)} line ({cleaned.Length} chars) exceeded the " +
+ $"{maxLineChars}-char per-line budget — no line is ever trimmed (SPEC F127.4)");
+ }
+
+ lines.Add(new CrosstalkLine(speaker, cleaned, isInterjection));
+ }
+
+ if (lines.All(line => line.Speaker != CrosstalkSpeaker.Host))
+ return Discarded($"no {HostTag} line appeared — both speakers must be present");
+ if (lines.All(line => line.Speaker != CrosstalkSpeaker.Neighbor))
+ return Discarded($"no {NeighborTag} line appeared — both speakers must be present");
+
+ for (var i = 1; i < lines.Count; i++)
+ {
+ // The sanctioned exception (SPEC F127.4): an interjection overlaps the tail of the
+ // previous line rather than following it in turn order, so it is exempt from the
+ // adjacent-pair alternation check below.
+ if (lines[i].IsInterjection)
+ continue;
+
+ if (lines[i].Speaker == lines[i - 1].Speaker)
+ {
+ return Discarded(
+ $"speaker alternation broken at line {i + 1} (mark an overlapping line as " +
+ $"'{InterjectionMarker}' instead)");
+ }
+ }
+
+ var totalChars = lines.Sum(line => line.Text.Length);
+ var estimatedSeconds = totalChars / CharsPerSecond;
+ if (estimatedSeconds > durationTargetSeconds)
+ {
+ return Discarded(
+ $"estimated {estimatedSeconds:F1}s exceeds the {durationTargetSeconds}s " +
+ $"{nameof(CrosstalkOptions.DurationTargetSeconds)} target");
+ }
+
+ return new CrosstalkWriteResult.Accepted(new CrosstalkScript(lines));
+ }
+
+ ///
+ /// One line's speaker tag, optional interjection marker, and spoken text, split on its FIRST
+ /// colon — a line with no colon, or whose pre-colon tag is neither nor
+ /// (after stripping an optional ),
+ /// fails to parse. Case-insensitive against both tokens: a small model's casing is not part of
+ /// the contract this parser enforces, only the STRUCTURE is.
+ ///
+ static bool TryParseLine(string line, out CrosstalkSpeaker speaker, out bool isInterjection, out string text)
+ {
+ speaker = default;
+ isInterjection = false;
+ text = "";
+
+ var colonIndex = line.IndexOf(':');
+ if (colonIndex <= 0)
+ return false;
+
+ var tag = line[..colonIndex].Trim();
+ text = line[(colonIndex + 1)..];
+
+ isInterjection = tag.EndsWith(InterjectionMarker, StringComparison.OrdinalIgnoreCase);
+ var roleToken = (isInterjection ? tag[..^InterjectionMarker.Length] : tag).Trim();
+
+ if (roleToken.Equals(HostTag, StringComparison.OrdinalIgnoreCase))
+ {
+ speaker = CrosstalkSpeaker.Host;
+ return true;
+ }
+
+ if (roleToken.Equals(NeighborTag, StringComparison.OrdinalIgnoreCase))
+ {
+ speaker = CrosstalkSpeaker.Neighbor;
+ return true;
+ }
+
+ return false;
+ }
+
+ static string DescribeSpeaker(CrosstalkSpeaker speaker) =>
+ speaker == CrosstalkSpeaker.Host ? HostTag : NeighborTag;
+
+ /// Truncates a raw model line to before it is echoed
+ /// into a discard reason (F127.11 review finding — see that constant's own remarks).
+ static string TruncateForEcho(string text) =>
+ text.Length <= MaxEchoedLineChars ? text : text[..MaxEchoedLineChars] + "…";
+
+ static CrosstalkWriteResult.Discarded Discarded(string reason) => new(reason);
+}
diff --git a/src/GenWave.Tts/CrosstalkScriptWriter.cs b/src/GenWave.Tts/CrosstalkScriptWriter.cs
new file mode 100644
index 0000000..e1ffba3
--- /dev/null
+++ b/src/GenWave.Tts/CrosstalkScriptWriter.cs
@@ -0,0 +1,189 @@
+namespace GenWave.Tts;
+
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+///
+/// Generates one two-voice banter exchange per call (SPEC F127.3, F127.4, STORY-326) — the
+/// GenWave.Tts half of the crosstalk feature (ARCHITECTURE.md "Crosstalk (F127…)"): casting
+/// (CrosstalkPlanner), per-line rendering, and assembly are ALL later tasks (T284-T287) this
+/// class never touches. Lives beside and shares its F123 machinery
+/// (, via
+/// , ) rather than duplicating it — see
+/// each reused member's own remarks for exactly why sharing that piece is safe.
+///
+///
+/// One completion, whole exchange (SPEC F127.3). Unlike , this
+/// writer NEVER degrades to a template — F127.4 is skip-only: any failure (disabled endpoint,
+/// transport fault, a completion truncated at max_tokens, malformed reply, a line failing
+/// hygiene/budget, an over-target duration estimate) returns
+/// with one reason, logged at Information
+/// (never WARN — banter is optional color, a miss is not an outage) and recorded into
+/// under so /api/llm-calls can
+/// answer "why was there no banter" (SPEC F127.11) exactly the way it already answers "why was there
+/// no lead-in" for .
+///
+///
+///
+/// No single-flight gate here (unlike 's own SPEC F69.6 seam).
+/// Crosstalk generation happens entirely off the on-air clock, ahead of air (SPEC F127.7) — a LATER
+/// task's thin stock-timer loop (T286) is what will pace how often this writer is actually called,
+/// which is the natural place to coordinate backend concurrency with the on-air copy path if that
+/// ever proves necessary; adding a shared gate here now, with no caller yet, would be speculative.
+///
+///
+///
+/// Registered as a DI singleton with NO eager I/O in its constructor (Story125's zero-I/O invariant)
+/// — every dependency here is itself a cheap seam (an options monitor, a ring, a logger, an
+/// ), so constructing this class never touches the network.
+///
+///
+public sealed class CrosstalkScriptWriter(
+ IHttpClientFactory httpClientFactory,
+ IOptionsMonitor llmOptions,
+ IOptionsMonitor crosstalkOptions,
+ LlmCallRing callRing,
+ IDegradationModeReader degradationMode,
+ ILogger logger,
+ TimeProvider timeProvider)
+{
+ ///
+ /// Requests one exchange. Never throws toward the caller for anything short of the caller's own
+ /// cancelling — every other fault (disabled endpoint, timeout, non-2xx,
+ /// connect, malformed/invalid script) resolves to
+ /// (SPEC F127.4's skip-only failure mode).
+ ///
+ public async Task WriteExchangeAsync(CrosstalkExchangeRequest request, CancellationToken ct)
+ {
+ var startedAt = timeProvider.GetUtcNow();
+ var mode = degradationMode.CurrentMode;
+ var personaName = $"{request.HostCard.Name} / {request.NeighborCard.Name}";
+
+ var cfg = llmOptions.CurrentValue;
+ if (string.IsNullOrEmpty(cfg.Endpoint))
+ return Discard("Llm:Endpoint is not configured", personaName, startedAt, mode, systemPrompt: null, userPrompt: null);
+
+ var durationTargetSeconds = crosstalkOptions.CurrentValue.DurationTargetSeconds;
+
+ var systemPrompt = CrosstalkPromptBuilder.BuildSystemPrompt(request.HostCard, request.NeighborCard, cfg.MaxCopyChars);
+ var userPrompt = CrosstalkPromptBuilder.BuildUserContent(
+ request, LlmPromptBuilder.BuildStationClockLine(request.StationLocalNow));
+
+ try
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(cfg.TimeoutSeconds));
+
+ var http = httpClientFactory.CreateClient(LlmCopyWriter.HttpClientName);
+ var requestUri = EndpointUri.Combine(cfg.Endpoint, "/v1/chat/completions");
+
+ var body = new
+ {
+ model = cfg.Model,
+ messages = new object[]
+ {
+ new { role = "system", content = systemPrompt },
+ new { role = "user", content = userPrompt },
+ },
+ // SPEC F127.3: "the F123.1 derived generation cap applies to the whole script" — the
+ // SAME formula LlmCopyWriter derives an ordinary blurb's cap from, not a second,
+ // banter-specific one (the one-knob discipline).
+ max_tokens = LlmCopyWriter.DeriveMaxTokens(cfg.MaxCopyChars),
+ };
+
+ using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
+ {
+ Content = JsonContent.Create(body),
+ };
+
+ if (!string.IsNullOrEmpty(cfg.ApiKey))
+ httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", cfg.ApiKey);
+
+ var response = await http.SendAsync(httpRequest, timeoutCts.Token);
+ response.EnsureSuccessStatusCode();
+
+ var payload = await response.Content.ReadFromJsonAsync(timeoutCts.Token);
+ var choice = payload?.Choices?.FirstOrDefault();
+ var raw = choice?.Message?.Content ?? string.Empty;
+
+ // SPEC F127.4, F127.11 (gh-#424 class, one seam over): a completion the backend cut
+ // short at its own max_tokens cap leaves a truncated last line that can still PARSE
+ // cleanly (a chopped sentence still matches the HOST:/NEIGHBOR: line shape) and would
+ // otherwise air mid-word — so this is checked BEFORE Parse ever runs, not left for the
+ // parser to maybe catch by accident. finish_reason is the OpenAI/ollama-compatible
+ // signal for exactly this (see ChatCompletionChoice.FinishReason's own remarks); "stop"
+ // (or a missing field, from an endpoint that predates it) never trips this check.
+ if (choice?.FinishReason == "length")
+ {
+ return Discard(
+ "the completion was cut short by max_tokens (finish_reason: length) — a truncated reply is never aired",
+ personaName, startedAt, mode, systemPrompt, userPrompt, raw);
+ }
+
+ var result = CrosstalkScriptParser.Parse(raw, cfg.MaxCopyChars, durationTargetSeconds);
+ return result switch
+ {
+ CrosstalkWriteResult.Accepted => Accept(result, personaName, systemPrompt, userPrompt, raw, startedAt, mode),
+ CrosstalkWriteResult.Discarded discarded => Discard(
+ discarded.Reason, personaName, startedAt, mode, systemPrompt, userPrompt, raw),
+ _ => throw new System.Diagnostics.UnreachableException($"Unhandled {nameof(CrosstalkWriteResult)} case."),
+ };
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // The caller cancelled (e.g. shutdown) — not our own Llm:TimeoutSeconds budget expiring,
+ // and not a call outcome worth a ring entry (mirrors LlmCopyWriter's own handling).
+ throw;
+ }
+ catch (Exception ex)
+ {
+ var (outcome, detail) = LlmCopyWriter.ClassifyForRing(ex);
+ callRing.Record(
+ personaName, systemPrompt, userPrompt, response: null, startedAt, ElapsedMs(startedAt),
+ outcome, detail, mode, LlmCallKind.Crosstalk);
+ logger.LogInformation(
+ "Crosstalk exchange discarded (persona: {PersonaName}): {Detail}",
+ personaName.ReplaceLineEndings(" "), detail.ReplaceLineEndings(" "));
+ return new CrosstalkWriteResult.Discarded(detail);
+ }
+ }
+
+ CrosstalkWriteResult Accept(
+ CrosstalkWriteResult result, string personaName, string systemPrompt, string userPrompt, string raw,
+ DateTimeOffset startedAt, DegradationMode mode)
+ {
+ callRing.Record(
+ personaName, systemPrompt, userPrompt, raw, startedAt, ElapsedMs(startedAt),
+ LlmCallOutcome.Ok, statusDetail: null, mode, LlmCallKind.Crosstalk);
+ return result;
+ }
+
+ ///
+ /// The one discard path every failure funnels through (SPEC F127.4) — records into
+ /// (skipped entirely when is null, i.e.
+ /// nothing was ever attempted — the disabled-endpoint short-circuit, mirroring
+ /// 's own "disabled means no ring entry" posture) and logs
+ /// exactly one Information line (never WARN — F127.4's own posture: a discard is discipline, not
+ /// an outage).
+ ///
+ CrosstalkWriteResult.Discarded Discard(
+ string reason, string personaName, DateTimeOffset startedAt, DegradationMode mode,
+ string? systemPrompt, string? userPrompt, string? raw = null)
+ {
+ if (systemPrompt is not null)
+ {
+ callRing.Record(
+ personaName, systemPrompt, userPrompt, raw, startedAt, ElapsedMs(startedAt),
+ LlmCallOutcome.Rejected, reason, mode, LlmCallKind.Crosstalk);
+ }
+
+ logger.LogInformation(
+ "Crosstalk exchange discarded (persona: {PersonaName}): {Reason}",
+ personaName.ReplaceLineEndings(" "), reason.ReplaceLineEndings(" "));
+ return new CrosstalkWriteResult.Discarded(reason);
+ }
+
+ long ElapsedMs(DateTimeOffset startedAt) => (long)(timeProvider.GetUtcNow() - startedAt).TotalMilliseconds;
+}
diff --git a/src/GenWave.Tts/CrosstalkSpeaker.cs b/src/GenWave.Tts/CrosstalkSpeaker.cs
new file mode 100644
index 0000000..c63da3b
--- /dev/null
+++ b/src/GenWave.Tts/CrosstalkSpeaker.cs
@@ -0,0 +1,18 @@
+namespace GenWave.Tts;
+
+///
+/// The two roles a -generated exchange ever casts (SPEC F127.1,
+/// F127.2, STORY-326) — never a third. is the on-air DJ;
+/// is the schedule-adjacent "drop-in" persona a later task (PLAN T284's CrosstalkPlanner)
+/// resolves from the grid. This writer never decides WHO fills either role — it only ever receives
+/// two already-cast s on
+/// and tags each generated line with which one spoke it.
+///
+public enum CrosstalkSpeaker
+{
+ /// The on-air host persona.
+ Host,
+
+ /// The schedule-adjacent drop-in persona (SPEC F127.2).
+ Neighbor,
+}
diff --git a/src/GenWave.Tts/CrosstalkWriteResult.cs b/src/GenWave.Tts/CrosstalkWriteResult.cs
new file mode 100644
index 0000000..c1bd255
--- /dev/null
+++ b/src/GenWave.Tts/CrosstalkWriteResult.cs
@@ -0,0 +1,29 @@
+namespace GenWave.Tts;
+
+///
+/// Outcome of (SPEC F127.3, F127.4, STORY-326)
+/// — mirrors 's closed-hierarchy shape (an
+/// accepted script always carries real, fully-validated lines; a discard always carries a reason,
+/// never both, never neither). There is no third "partial" case and no template/salvage rung by
+/// design (F127.4): every failure — a transport fault, a malformed reply, a line that fails hygiene
+/// or its budget, an over-target duration estimate — collapses to , and the
+/// caller (a LATER task's CrosstalkPlanner/stock-timer loop) simply tries again on its own
+/// cadence rather than distinguishing WHY this attempt produced nothing.
+///
+public abstract record CrosstalkWriteResult
+{
+ CrosstalkWriteResult() { }
+
+ /// A fully validated, ready-to-render two-voice script.
+ public sealed record Accepted(CrosstalkScript Script) : CrosstalkWriteResult;
+
+ ///
+ /// No exchange was produced. is the SAME text logged at Information (SPEC
+ /// F127.4 — a discard is never a WARN, banter is optional color) and recorded as
+ /// under (a content
+ /// validation miss) or / (a
+ /// transport miss) — one string, one source of truth for "why was there no banter" across the log
+ /// line, the ring, and this return value.
+ ///
+ public sealed record Discarded(string Reason) : CrosstalkWriteResult;
+}
diff --git a/src/GenWave.Tts/LlmCallKind.cs b/src/GenWave.Tts/LlmCallKind.cs
new file mode 100644
index 0000000..372fa98
--- /dev/null
+++ b/src/GenWave.Tts/LlmCallKind.cs
@@ -0,0 +1,19 @@
+namespace GenWave.Tts;
+
+///
+/// Which generation surface produced an entry (SPEC F73.1, F127.11, PLAN
+/// T282) — orthogonal to (what happened) and to
+/// (what the ladder was doing at the time). Every call
+/// itself records — on-air, Soft-cadence, or an operator preview alike —
+/// is ; 's own completion calls are
+/// , so an operator reading /api/llm-calls can tell "why was there no
+/// banter" apart from an ordinary blurb miss without re-deriving it from the prompt text (F127.11).
+///
+public enum LlmCallKind
+{
+ /// An ordinary segment-copy completion () — LeadIn, BackAnnounce, SignOff, SignOn, ContextSegment, or a persona preview.
+ Copy,
+
+ /// A two-voice banter script completion (, SPEC F127.3).
+ Crosstalk,
+}
diff --git a/src/GenWave.Tts/LlmCallOutcome.cs b/src/GenWave.Tts/LlmCallOutcome.cs
index 53c8449..15f93d9 100644
--- a/src/GenWave.Tts/LlmCallOutcome.cs
+++ b/src/GenWave.Tts/LlmCallOutcome.cs
@@ -41,4 +41,17 @@ public enum LlmCallOutcome
/// segment still airs the trimmed copy (see 's own remarks).
///
Trimmed,
+
+ ///
+ /// The completions endpoint returned 2xx, but the reply failed CONTENT validation — a
+ /// -only outcome (SPEC F127.4, PLAN T282): the script didn't
+ /// parse (wrong line count, an unrecognized speaker tag, broken alternation), a line failed
+ /// hygiene or its per-line budget, or the estimated spoken duration exceeded the configured
+ /// target. Split out from the same way split out from
+ /// above — a validation reject is a content-quality decision this project made
+ /// on a genuinely successful HTTP call, never a transport/endpoint fault. Unlike
+ /// , there is no salvage here: F127.4 is skip-only, no trim, no template
+ /// rung — carries the discard reason.
+ ///
+ Rejected,
}
diff --git a/src/GenWave.Tts/LlmCallRecord.cs b/src/GenWave.Tts/LlmCallRecord.cs
index 674f223..3b8f202 100644
--- a/src/GenWave.Tts/LlmCallRecord.cs
+++ b/src/GenWave.Tts/LlmCallRecord.cs
@@ -37,9 +37,17 @@ namespace GenWave.Tts;
///
/// When this call was dispatched (includes any single-flight queueing wait — mirrors 's own attemptedAt semantics).
/// Wall-clock duration from to completion (success or failure).
-/// ok/failed/timeout/trimmed (SPEC F73.1, F123.2-F123.4).
-/// The HTTP status or exception type name for a non- outcome; for Ok and for alike — a trim is not a fault, so it carries no fault detail either.
+/// ok/failed/timeout/trimmed/rejected (SPEC F73.1, F123.2-F123.4, F127.4).
+/// The HTTP status or exception type name for a non- outcome; for Ok and for alike — a trim is not a fault, so it carries no fault detail either. Carries the discard reason for (SPEC F127.4, F127.11).
/// The degradation mode active at call time (SPEC F73.1, F69.1) — Normal/Soft/Hard.
+///
+/// Which generation surface produced this call (SPEC F127.11, PLAN T282) —
+/// for every call itself records, for
+/// 's own. Defaults to so
+/// 's two pre-existing call sites (inside
+/// ) needed no change at all when this
+/// parameter was added.
+///
public sealed record LlmCallRecord(
long Seq,
string? PersonaName,
@@ -50,4 +58,5 @@ public sealed record LlmCallRecord(
long ElapsedMs,
LlmCallOutcome Outcome,
string? StatusDetail,
- DegradationMode Mode);
+ DegradationMode Mode,
+ LlmCallKind Kind = LlmCallKind.Copy);
diff --git a/src/GenWave.Tts/LlmCallRing.cs b/src/GenWave.Tts/LlmCallRing.cs
index 9db1719..e065c8f 100644
--- a/src/GenWave.Tts/LlmCallRing.cs
+++ b/src/GenWave.Tts/LlmCallRing.cs
@@ -43,17 +43,20 @@ public sealed class LlmCallRing(IOptionsMonitor options)
/// Llm:CallRingCapacity trims (or grows) the ring on the very next record.
/// (gh-#429) is the caller's already-resolved
/// result — this ring never re-derives a name itself,
- /// it only stores what it was handed.
+ /// it only stores what it was handed. (SPEC F127.11, PLAN T282) defaults
+ /// to so 's own two call sites need no
+ /// change — only ever passes .
///
public void Record(
string? personaName, string? promptSystem, string? promptUser, string? response, DateTimeOffset startedAt,
- long elapsedMs, LlmCallOutcome outcome, string? statusDetail, DegradationMode mode)
+ long elapsedMs, LlmCallOutcome outcome, string? statusDetail, DegradationMode mode,
+ LlmCallKind kind = LlmCallKind.Copy)
{
lock (gate)
{
var record = new LlmCallRecord(
++nextSeq, personaName, promptSystem, promptUser, response, startedAt, elapsedMs, outcome,
- statusDetail, mode);
+ statusDetail, mode, kind);
ring.AddFirst(record); // newest first
var capacity = options.CurrentValue.CallRingCapacity;
diff --git a/src/GenWave.Tts/LlmCopyStatusHolder.cs b/src/GenWave.Tts/LlmCopyStatusHolder.cs
index 6e8a5d0..e8133a3 100644
--- a/src/GenWave.Tts/LlmCopyStatusHolder.cs
+++ b/src/GenWave.Tts/LlmCopyStatusHolder.cs
@@ -4,8 +4,8 @@ namespace GenWave.Tts;
/// In-memory record of the most recent completion attempt (SPEC F34.8).
/// Singleton, process-lifetime only — no persistence and no active health-polling; GET
/// /api/status (STORY-125) reads whatever the last render produced. A disabled writer and the
-/// templated kinds (StationId/TimeDate) never call — this holder reflects LLM
-/// attempts only.
+/// templated kinds (StationId/TimeDate/Crosstalk) never call — this holder
+/// reflects LLM attempts only.
///
/// (SPEC F69.2, STORY-188) is what
/// reads for the auto-drop side of the mode state machine — the
diff --git a/src/GenWave.Tts/LlmCopyWriter.cs b/src/GenWave.Tts/LlmCopyWriter.cs
index 0c01e92..08a3a49 100644
--- a/src/GenWave.Tts/LlmCopyWriter.cs
+++ b/src/GenWave.Tts/LlmCopyWriter.cs
@@ -14,9 +14,11 @@ namespace GenWave.Tts;
/// copy for exactly the kinds reports true for — ,
/// , ,
/// , and, as of T224, —
-/// from an OpenAI-compatible chat-completions endpoint. and
-/// always delegate straight to with
-/// zero HTTP — brand/time copy stays fixed and forever-cached.
+/// from an OpenAI-compatible chat-completions endpoint. ,
+/// , and always delegate
+/// straight to with zero HTTP — brand/time copy stays fixed and
+/// forever-cached, and Crosstalk's real copy arrives via its own ahead-of-air script writer
+/// (T282, SPEC F127.3) rather than this seam.
/// Enabled-ness and every other option are read from fresh on each
/// call (F36.2) — an empty Llm:Endpoint means disabled. Any failure (disabled, timeout,
/// non-2xx, connect, empty/over-length copy) degrades to 's template copy
@@ -241,9 +243,9 @@ public sealed class LlmCopyWriter(
/// non-fresh-copy guard, extended alongside SignOff/SignOn for exactly that reason). Gates both
/// and so the two can never drift apart,
/// and is the fact 's own exhaustiveness switch
- /// relies on staying in sync with (see that method's remarks):
- /// and are the only two kinds this reports false for, and they
- /// never reach a prompt at all.
+ /// relies on staying in sync with (see that method's remarks): ,
+ /// , and (as of PLAN T281)
+ /// are the three kinds this reports false for today, and none of them ever reach a prompt.
///
static bool IsLlmAuthored(SegmentKind kind) =>
kind is SegmentKind.LeadIn or SegmentKind.BackAnnounce or SegmentKind.SignOff or SegmentKind.SignOn
@@ -252,8 +254,11 @@ kind is SegmentKind.LeadIn or SegmentKind.BackAnnounce or SegmentKind.SignOff or
public async Task WriteAsync(SegmentRequest request, CancellationToken ct)
{
// StationId/TimeDate stay templated — brand/time copy must be crisp, consistent, and
- // forever-cacheable; the two track-anchored kinds (F34.2) and the two handoff kinds (F92.2,
- // F92.5) are the ones worth an LLM's while (see IsLlmAuthored, the single source of truth).
+ // forever-cacheable. Crosstalk (PLAN T281) also stays templated here, for a different
+ // reason: its real copy arrives via its own ahead-of-air script writer (T282, SPEC F127.3),
+ // not this seam, so this writer is template-class for it by design, not degrading. The two
+ // track-anchored kinds (F34.2) and the two handoff kinds (F92.2, F92.5) are the ones worth an
+ // LLM's while (see IsLlmAuthored, the single source of truth).
if (!IsLlmAuthored(request.Kind))
return await fallback.WriteAsync(request, ct);
@@ -358,9 +363,11 @@ public async Task WriteAsync(SegmentRequest request, CancellationTo
public async Task WritePreviewAsync(
SegmentRequest request, Persona? personaOverride, CancellationToken ct)
{
- // StationId/TimeDate route straight to the template rung — mirrors WriteAsync's own
- // kind-based routing (F34.2, IsLlmAuthored). This is not a fallback: those two kinds never
- // call the LLM on-air either, so template text IS the correct preview for them.
+ // StationId/TimeDate/Crosstalk route straight to the template rung — mirrors WriteAsync's
+ // own kind-based routing (F34.2, IsLlmAuthored; see that method's own comment for why
+ // Crosstalk joins the two — its real copy arrives via its own ahead-of-air script writer,
+ // T282, SPEC F127.3, not this seam). This is not a fallback: none of the three call the LLM
+ // on-air either, so template text IS the correct preview for them.
if (!IsLlmAuthored(request.Kind))
{
var templated = await fallback.WriteAsync(request, ct);
@@ -703,7 +710,7 @@ DateTimeOffset StationLocalNow() =>
/// out timeout, so duplicating this small a classification is simpler than threading a shared
/// helper through two call sites with different needs.
///
- static (LlmCallOutcome Outcome, string Detail) ClassifyForRing(Exception ex) => ex switch
+ internal static (LlmCallOutcome Outcome, string Detail) ClassifyForRing(Exception ex) => ex switch
{
OperationCanceledException => (LlmCallOutcome.Timeout, "Llm:TimeoutSeconds exceeded"),
HttpRequestException { StatusCode: { } status } => (LlmCallOutcome.Failed, $"HTTP {(int)status}"),
@@ -716,9 +723,13 @@ DateTimeOffset StationLocalNow() =>
/// , and for why the
/// divisor, floor, and ceiling are what they are. Applied identically to the on-air path and
/// the preview path, since both funnel through 's single
- /// request-builder.
+ /// request-builder. Internal (PLAN T282, SPEC F127.3: "the F123.1 derived generation cap applies
+ /// to the whole script") — derives its own completion's
+ /// max_tokens through this exact formula rather than a second, independently-tuned one;
+ /// the one-knob discipline (a single Llm:MaxCopyChars) extends to banter with zero new
+ /// generation-cap machinery.
///
- static int DeriveMaxTokens(int maxCopyChars) =>
+ internal static int DeriveMaxTokens(int maxCopyChars) =>
Math.Clamp(maxCopyChars / CharsPerTokenDivisor, MinGenerationTokenCap, MaxGenerationTokenCap);
///
@@ -738,17 +749,18 @@ static int DeriveMaxTokens(int maxCopyChars) =>
};
///
- /// Copy hygiene (SPEC F34.5) plus the F123.2 sentence-boundary salvage (STORY-319, PLAN T263):
- /// trims, unwraps one layer of wrapping quotes, collapses newlines to spaces, and strips stage
- /// directions and markdown emphasis markers. A result that still exceeds
- /// after that hygiene is no longer an automatic reject — it is cut at the LAST complete sentence
- /// that fits under the cap (see ), never mid-sentence,
- /// and never at an abbreviation's own period (see that method's own remarks).
- /// is reserved for the cases nothing salvages:
- /// hygiene left an empty string, or nothing complete under the cap survives that filter — no
- /// candidate at all, or every candidate under the cap was an abbreviation/lone-initial period.
+ /// The hygiene pass every LLM-authored line in this project runs through (SPEC F34.5): trims,
+ /// strips a chat preamble, unwraps one layer of wrapping quotes, collapses newlines to spaces,
+ /// and strips stage directions and markdown emphasis markers. Deliberately excludes the F123.2
+ /// sentence-boundary SALVAGE below — that is a length-policy decision
+ /// layers on top for the ordinary on-air/preview path, and 's
+ /// own per-line validation (SPEC F127.4: cleared, never trimmed) needs the SAME text transform
+ /// with a completely different length policy (reject the whole exchange, never cut a line).
+ /// Internal (PLAN T282 extraction) — a small shared helper rather than a second hand-maintained
+ /// copy of these five steps, with zero change to 's own byte-for-byte
+ /// output (a pure extract-method refactor).
///
- static LlmCopyCleanupResult CleanCopy(string raw, int maxChars)
+ internal static string ApplyCopyHygiene(string raw)
{
var text = StripChatPreamble(raw.Trim()); // gh-#186 — must run BEFORE quote unwrapping
text = StripWrappingQuotes(text);
@@ -756,7 +768,22 @@ static LlmCopyCleanupResult CleanCopy(string raw, int maxChars)
text = BracketStageDirectionPattern.Replace(text, string.Empty);
text = AsteriskStageDirectionPattern.Replace(text, string.Empty);
text = MarkdownEmphasisPattern.Replace(text, string.Empty);
- text = RepeatedWhitespacePattern.Replace(text, " ").Trim();
+ return RepeatedWhitespacePattern.Replace(text, " ").Trim();
+ }
+
+ ///
+ /// Copy hygiene (, SPEC F34.5) plus the F123.2 sentence-boundary
+ /// salvage (STORY-319, PLAN T263): a result that still exceeds after
+ /// hygiene is no longer an automatic reject — it is cut at the LAST complete sentence that fits
+ /// under the cap (see ), never mid-sentence, and never at
+ /// an abbreviation's own period (see that method's own remarks).
+ /// is reserved for the cases nothing salvages:
+ /// hygiene left an empty string, or nothing complete under the cap survives that filter — no
+ /// candidate at all, or every candidate under the cap was an abbreviation/lone-initial period.
+ ///
+ static LlmCopyCleanupResult CleanCopy(string raw, int maxChars)
+ {
+ var text = ApplyCopyHygiene(raw);
if (text.Length == 0)
return new LlmCopyCleanupResult.Rejected();
diff --git a/src/GenWave.Tts/LlmPromptBuilder.cs b/src/GenWave.Tts/LlmPromptBuilder.cs
index 1267d04..0171680 100644
--- a/src/GenWave.Tts/LlmPromptBuilder.cs
+++ b/src/GenWave.Tts/LlmPromptBuilder.cs
@@ -611,7 +611,8 @@ public static bool IsPatterFactKind(SegmentKind kind) =>
/// this break is so the model never has to guess its own role. Only ever called with a kind
/// reports true for — the single source of truth for
/// "which kinds"; the remaining kinds (,
- /// ) never reach the LLM and so never reach this method either.
+ /// , ) never reach the LLM
+ /// and so never reach this method either.
/// Exhaustive switch below: a new LLM-eligible needs a matching arm
/// added HERE as well as in for it to actually take
/// effect end to end — the compiler's own exhaustiveness check on this switch is the guard
diff --git a/src/GenWave.Tts/PatterTemplateRenderer.cs b/src/GenWave.Tts/PatterTemplateRenderer.cs
index f044c5d..2af7bac 100644
--- a/src/GenWave.Tts/PatterTemplateRenderer.cs
+++ b/src/GenWave.Tts/PatterTemplateRenderer.cs
@@ -102,8 +102,19 @@ public sealed class PatterTemplateRenderer
// (T224 review finding): ContextSegment is now one of LlmCopyWriter.IsLlmAuthored's kinds, so
// WritePreviewAsync never falls through to this template rung on an LLM miss the way it would
// have under T223 — it returns PersonaPreviewResult.Failed instead (F35.6: a preview never
- // silently substitutes template copy).
+ // silently substitutes template copy). The sibling Crosstalk arm just below carries no such
+ // guarantee: TtsSegmentSource's own non-LLM-authored drop guard (`SignOff or SignOn or
+ // ContextSegment`, TtsSegmentSource.cs:93) does not list Crosstalk, so if a future producer
+ // (T287) ever routes a Crosstalk render through this template rung, "Two voices, one moment."
+ // would become airable filler unless that guard is extended first — the wiring task's call.
SegmentKind.ContextSegment => "Here's something worth knowing.",
+ // No producer builds a Crosstalk SegmentRequest yet (SPEC F127.1, PLAN T281 — the vend
+ // itself is T287's), so this arm never reaches air today. It exists purely so the switch
+ // below stays total: TemplateCopyWriter's own "never fails for any SegmentRequest" contract,
+ // and POST /api/personas/preview (kind is validated against any real SegmentKind name, see
+ // PersonaController.TryParseKind), both need a correct, non-throwing landing spot for this
+ // kind now that it exists — the same discipline ContextSegment's arm above was added under.
+ SegmentKind.Crosstalk => "Two voices, one moment.",
_ => throw new ArgumentOutOfRangeException(
nameof(request.Kind), request.Kind, message: null),
};
diff --git a/src/GenWave.Tts/TtsServiceCollectionExtensions.cs b/src/GenWave.Tts/TtsServiceCollectionExtensions.cs
index 6de567c..a522336 100644
--- a/src/GenWave.Tts/TtsServiceCollectionExtensions.cs
+++ b/src/GenWave.Tts/TtsServiceCollectionExtensions.cs
@@ -93,6 +93,21 @@ public static IServiceCollection AddGenWaveTts(this IServiceCollection services,
.ValidateDataAnnotations()
.ValidateOnStart();
+ // Crosstalk two-voice banter (SPEC F127.4, F127.8, STORY-326, PLAN T282) — the ONE knob
+ // CrosstalkScriptWriter reads today (DurationTargetSeconds); Crosstalk:Shows/EveryNthAiring
+ // (F127.8) join this same section in a LATER task (T284's CrosstalkPlanner), not here.
+ // IOptionsMonitor (not IOptions), mirroring every other live-adjustable
+ // options class in this method — a live PUT reaches the very next generation attempt with no
+ // api restart. CrosstalkScriptWriter is a plain singleton with zero eager I/O in its
+ // constructor (Story125's zero-I/O invariant) — every dependency it takes is itself a cheap
+ // seam, so registering it here never touches the network.
+ services
+ .AddOptions()
+ .Bind(configuration.GetSection(CrosstalkOptions.Section))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+ services.AddSingleton();
+
// Injected clock for DegradationController's cooldown math (no DateTime.Now anywhere in
// this feature) — TryAdd so a host or test that already registers its own TimeProvider wins.
services.TryAddSingleton(TimeProvider.System);
diff --git a/tests/GenWave.Architecture.Tests/Support/HttpClientSeams.cs b/tests/GenWave.Architecture.Tests/Support/HttpClientSeams.cs
index 365e24e..f479a40 100644
--- a/tests/GenWave.Architecture.Tests/Support/HttpClientSeams.cs
+++ b/tests/GenWave.Architecture.Tests/Support/HttpClientSeams.cs
@@ -63,7 +63,9 @@ internal static class HttpClientSeams
///
/// One entry per production type this suite's detector finds depending on the
/// family: TTS/LLM (typed-client injection; Kokoro/Piper/Ollama
- /// synthesis, voice listing, health probes, LLM copywriting) plus its composition root
+ /// synthesis, voice listing, health probes, LLM copywriting, PLAN T282's crosstalk script
+ /// writing — it reuses LlmCopyWriter's own named client rather than minting a second one,
+ /// same shape as LlmWishParser below) plus its composition root
/// (TtsServiceCollectionExtensions, whose AddHttpClient<T> calls are the
/// construction site the DI container itself never exposes); MediaLibrary's Ollama
/// mood/explicit enrichment and MusicBrainz year lookup (same shape) plus its composition root
@@ -87,6 +89,7 @@ internal static class HttpClientSeams
"GenWave.Tts.PiperHealthProbe",
"GenWave.Tts.OllamaHealthProbe",
"GenWave.Tts.LlmCopyWriter",
+ "GenWave.Tts.CrosstalkScriptWriter",
"GenWave.Tts.TtsServiceCollectionExtensions",
// MediaLibrary enrichment (Ollama mood/explicit, MusicBrainz year lookup).
diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story328_StockedAheadAiredOnce.cs b/tests/GenWave.Orchestration.Tests/Specs/Story328_StockedAheadAiredOnce.cs
new file mode 100644
index 0000000..6b5803c
--- /dev/null
+++ b/tests/GenWave.Orchestration.Tests/Specs/Story328_StockedAheadAiredOnce.cs
@@ -0,0 +1,112 @@
+// STORY-328 — Stocked ahead, aired once (gh-#385 · SPEC F127.2/.7 · PLAN VQ-i, T285–T286)
+//
+// BDD specification — xUnit, pending until /build-loop turns them green. The single-use
+// queue ruling: exchanges generate and render OFF the on-air clock (LLM latency stops
+// mattering), air once, retire at air — re-airing is the "said that before" artifact the
+// 07-31 evergreen rejection named. Casting comes free from the grid (drop-in neighbor:
+// no authoring surface, no show→persona reference). No schema: a restart regenerates
+// (the F125.4 durability posture). One assertion per Fact; happy first; sad segregated.
+// The T288 wire acceptance is a production check, not represented here.
+// ⛔ Gated behind T283's paper-audition go.
+
+namespace GenWave.Orchestration.Tests.Specs;
+
+public static class FeatureStockedAheadAiredOnce
+{
+ // ── HAPPY PATH ──────────────────────────────────────────────────────────
+
+ public static class ScenarioCastingComesFreeFromTheGrid
+ {
+ [Fact(Skip = "Pending T285 — see docs/PLAN.md")]
+ public static void The_second_voice_is_the_next_blocks_persona_when_one_exists()
+ {
+ // Given an enabled show whose grid neighbor is a distinct persona
+ // Then host = the show's DJ, second = the NEXT block's persona
+ // (tease-forward is radio-natural)
+ Assert.Fail("pending T285");
+ }
+
+ [Fact(Skip = "Pending T285 — see docs/PLAN.md")]
+ public static void The_previous_blocks_persona_casts_when_no_next_exists()
+ {
+ Assert.Fail("pending T285");
+ }
+ }
+
+ public static class ScenarioTheStockFillsOffTheClock
+ {
+ [Fact(Skip = "Pending T286 — see docs/PLAN.md")]
+ public static void A_show_below_its_stock_target_triggers_generation()
+ {
+ // Target: ≤2 ready exchanges per enabled show.
+ Assert.Fail("pending T286");
+ }
+
+ [Fact(Skip = "Pending T286 — see docs/PLAN.md")]
+ public static void The_worker_never_generates_or_renders_inside_a_break_window()
+ {
+ // Off the on-air clock, always — the render fence serves air first.
+ Assert.Fail("pending T286");
+ }
+ }
+
+ public static class ScenarioAiredOnceRetiredAtAir
+ {
+ [Fact(Skip = "Pending T285 — see docs/PLAN.md")]
+ public static void Retirement_deletes_the_aired_exchanges_asset()
+ {
+ Assert.Fail("pending T285");
+ }
+
+ [Fact(Skip = "Pending T285 — see docs/PLAN.md")]
+ public static void A_retired_exchange_can_never_vend_again()
+ {
+ Assert.Fail("pending T285");
+ }
+ }
+
+ public static class ScenarioAScheduleEditInvalidatesTheCast
+ {
+ [Fact(Skip = "Pending T285 — see docs/PLAN.md")]
+ public static void A_stale_cast_pair_is_discarded_at_vend_with_one_reason_line()
+ {
+ // Given a stocked exchange whose cast no longer matches grid adjacency
+ Assert.Fail("pending T285");
+ }
+
+ [Fact(Skip = "Pending T285 — see docs/PLAN.md")]
+ public static void A_discarded_stale_exchange_is_restocked()
+ {
+ Assert.Fail("pending T285");
+ }
+ }
+
+ // ── SAD PATH ────────────────────────────────────────────────────────────
+
+ public static class ScenarioNoDistinctNeighborNoExchange
+ {
+ [Fact(Skip = "Pending T285 — see docs/PLAN.md")]
+ public static void Adjacent_blocks_sharing_the_host_persona_skip_the_airing()
+ {
+ // The host never banters with themself.
+ Assert.Fail("pending T285");
+ }
+
+ [Fact(Skip = "Pending T285 — see docs/PLAN.md")]
+ public static void No_adjacent_persona_at_all_skips_the_airing()
+ {
+ Assert.Fail("pending T285");
+ }
+ }
+
+ public static class ScenarioARestartForgetsAndThatIsFine
+ {
+ [Fact(Skip = "Pending T285 — see docs/PLAN.md")]
+ public static void No_stock_state_survives_a_restart()
+ {
+ // No persisted queue exists — the stock regenerates from nothing, and
+ // retirement-by-deletion means nothing ever airs twice.
+ Assert.Fail("pending T285");
+ }
+ }
+}
diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story329_BanterOnTheAir.cs b/tests/GenWave.Orchestration.Tests/Specs/Story329_BanterOnTheAir.cs
new file mode 100644
index 0000000..4e6432e
--- /dev/null
+++ b/tests/GenWave.Orchestration.Tests/Specs/Story329_BanterOnTheAir.cs
@@ -0,0 +1,133 @@
+// STORY-329 — Banter on the air (gh-#385 · SPEC F127.1/.8/.9 · PLAN VQ-i, T281 + T287)
+//
+// BDD specification — xUnit, pending until /build-loop turns them green. Banter owns its
+// moment: a new SegmentKind vending at mid-block seams only (the F92/F124 boundary ladder
+// structurally untouched), superseding the F107.5/F116 gated lanes in any break it airs —
+// one voice-moment per break, the epic's recorded #1 risk honored. `Crosstalk:Shows`
+// empty = OFF, fail-closed: no station's sound changes on upgrade. One assertion per
+// Fact; happy first; sad segregated. The T288 wire acceptance (byte-identical air with
+// the list emptied, on the production binary) is a production check, not represented
+// here. ⛔ T287 carries the Orchestrator drain-region serialization flag.
+
+using GenWave.Core.Domain;
+
+namespace GenWave.Orchestration.Tests.Specs;
+
+public static class FeatureBanterOnTheAir
+{
+ // ── HAPPY PATH ──────────────────────────────────────────────────────────
+
+ public static class ScenarioANewKindAtAMidBlockSeam
+ {
+ [Fact]
+ public static void SegmentKind_Crosstalk_exists_as_an_additive_member()
+ {
+ // The published Abstractions contract grows by one enum member — minor version, no
+ // binary break. One assertion: the full ordered underlying-value sequence, so
+ // "Crosstalk is additive" (appended last) and "every pre-existing member keeps its
+ // original value" are pinned together, not as separate facts.
+ Assert.Equal(
+ [0, 1, 2, 3, 4, 5, 6, 7],
+ Enum.GetValues().Select(kind => (int)kind));
+ }
+
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void A_due_exchange_vends_at_a_mid_block_break_seam()
+ {
+ Assert.Fail("pending T287");
+ }
+
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void No_exchange_ever_vends_inside_the_boundary_ceremony_window()
+ {
+ // The F92/F124 ladder is untouched by construction — banter and
+ // ceremony never share a moment.
+ Assert.Fail("pending T287");
+ }
+ }
+
+ public static class ScenarioBanterSupersedesTheGatedLanes
+ {
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void No_show_flavor_line_airs_in_a_crosstalk_break()
+ {
+ Assert.Fail("pending T287");
+ }
+
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void No_context_patter_fact_airs_in_a_crosstalk_break()
+ {
+ Assert.Fail("pending T287");
+ }
+ }
+
+ public static class ScenarioTheCadenceKnob
+ {
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void One_exchange_airs_per_Nth_eligible_airing_of_an_enabled_show()
+ {
+ // Crosstalk:EveryNthAiring — Dean's "1 every X shows" knob.
+ Assert.Fail("pending T287");
+ }
+
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void The_cadence_setting_is_live_editable_with_a_default_of_one()
+ {
+ Assert.Fail("pending T287");
+ }
+ }
+
+ public static class ScenarioTheAiredScriptIsOnTheRecord
+ {
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void The_booth_row_carries_the_full_script_in_its_stamp()
+ {
+ // The `pick jsonb` precedent — "what did they say" is answerable from
+ // the booth log, not just the ear.
+ Assert.Fail("pending T287");
+ }
+
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void The_demo_hour_instrument_counts_a_Crosstalk_row_like_any_kind()
+ {
+ Assert.Fail("pending T287");
+ }
+ }
+
+ // ── SAD PATH ────────────────────────────────────────────────────────────
+
+ public static class ScenarioAnEmptyListMeansOffByteIdentical
+ {
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void With_Crosstalk_Shows_empty_no_exchange_ever_vends()
+ {
+ // The shipped default — fail-closed, the sounds-identical-on-upgrade
+ // discipline.
+ Assert.Fail("pending T287");
+ }
+
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void With_Crosstalk_Shows_empty_the_gated_lane_arbitration_is_unchanged()
+ {
+ // The F107.5/F116 golden holds byte-for-byte when the feature is off.
+ Assert.Fail("pending T287");
+ }
+ }
+
+ public static class ScenarioAnEmptyStockSkipsSilently
+ {
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void A_due_airing_with_no_ready_exchange_skips_the_slot()
+ {
+ Assert.Fail("pending T287");
+ }
+
+ [Fact(Skip = "Pending T287 — see docs/PLAN.md")]
+ public static void The_skipped_break_proceeds_with_its_ordinary_lanes()
+ {
+ // Skip means the break falls back to flavor/fact arbitration as if
+ // crosstalk never existed — banter's absence costs nothing.
+ Assert.Fail("pending T287");
+ }
+ }
+}
diff --git a/tests/GenWave.Tts.Tests/MockCompletionsServer.cs b/tests/GenWave.Tts.Tests/MockCompletionsServer.cs
index da26a39..ceeebc5 100644
--- a/tests/GenWave.Tts.Tests/MockCompletionsServer.cs
+++ b/tests/GenWave.Tts.Tests/MockCompletionsServer.cs
@@ -40,6 +40,16 @@ sealed class MockCompletionsServer : IAsyncDisposable
/// Completion text served in /.
public volatile string ReplyContent = "Great tune coming up, stay tuned.";
+ ///
+ /// finish_reason served alongside (SPEC F127.4, F127.11,
+ /// PLAN T282) — the OpenAI/ollama-compatible per-choice field (see
+ /// GenWave.Tts.ChatCompletionChoice.FinishReason's own remarks). Defaults to
+ /// "stop" — a normal, non-truncated completion — so every pre-existing spec against
+ /// this stub is unaffected; a test flips this to "length" to simulate a backend that
+ /// cut the reply short at its own max_tokens cap.
+ ///
+ public volatile string? ReplyFinishReason = "stop";
+
/// The base URI the stub is listening on (e.g. http://127.0.0.1:12345).
public Uri BaseUri { get; }
@@ -98,12 +108,12 @@ public static async Task StartAsync(MockCompletionsMode i
case MockCompletionsMode.Delay:
// Longer than any test's Llm:TimeoutSeconds (tests use 1-2s budgets).
await Task.Delay(TimeSpan.FromSeconds(30), ctx.RequestAborted);
- await WriteReplyAsync(ctx, server.ReplyContent);
+ await WriteReplyAsync(ctx, server.ReplyContent, server.ReplyFinishReason);
return;
case MockCompletionsMode.Serve:
default:
- await WriteReplyAsync(ctx, server.ReplyContent);
+ await WriteReplyAsync(ctx, server.ReplyContent, server.ReplyFinishReason);
return;
}
});
@@ -118,11 +128,11 @@ public static async Task StartAsync(MockCompletionsMode i
public async ValueTask DisposeAsync() => await app.DisposeAsync();
- static Task WriteReplyAsync(HttpContext ctx, string content)
+ static Task WriteReplyAsync(HttpContext ctx, string content, string? finishReason)
{
ctx.Response.StatusCode = 200;
return ctx.Response.WriteAsJsonAsync(
- new { choices = new[] { new { message = new { content } } } },
+ new { choices = new[] { new { message = new { content }, finish_reason = finishReason } } },
ctx.RequestAborted);
}
}
diff --git a/tests/GenWave.Tts.Tests/Specs/Story005_TtsSegmentSource.cs b/tests/GenWave.Tts.Tests/Specs/Story005_TtsSegmentSource.cs
index e6f4fae..482ca07 100644
--- a/tests/GenWave.Tts.Tests/Specs/Story005_TtsSegmentSource.cs
+++ b/tests/GenWave.Tts.Tests/Specs/Story005_TtsSegmentSource.cs
@@ -112,11 +112,11 @@ public void SegmentRequestRecordExistsInAbstractions()
}
[Fact]
- public void SegmentKindEnumHasSevenCases()
+ public void SegmentKindEnumHasEightCases()
{
- // Bumped from six (STORY-297, PLAN T223): ContextSegment joined the enum (SPEC F107.3).
+ // Bumped from seven (STORY-329, PLAN T281): Crosstalk joined the enum (SPEC F127.1).
var values = Enum.GetValues();
- Assert.Equal(7, values.Length);
+ Assert.Equal(8, values.Length);
}
}
diff --git a/tests/GenWave.Tts.Tests/Specs/Story326_BoothWritesForTwo.cs b/tests/GenWave.Tts.Tests/Specs/Story326_BoothWritesForTwo.cs
new file mode 100644
index 0000000..085fa13
--- /dev/null
+++ b/tests/GenWave.Tts.Tests/Specs/Story326_BoothWritesForTwo.cs
@@ -0,0 +1,520 @@
+// STORY-326 — The booth writes for two (gh-#385 · SPEC F127.3/.4 · PLAN VQ-i, T282)
+//
+// BDD specification — xUnit, LIVE as of T282. The design's named risk (2026-08-14): a 3B model
+// writing two coherent voices — these facts pin the contract that makes bad output unairable, not
+// the output good. One completion produces the WHOLE exchange (reactions must react to what was
+// actually said); validation is fail-closed and the failure mode is silent skip — no template rung,
+// no salvage. One assertion per Fact; happy first; sad path segregated. The T288 wire acceptance (an
+// exchange airs once on a running stack) is a production check, not represented here. T283, the
+// paper-audition checkpoint, gates everything after T282 — these facts green first.
+
+namespace GenWave.Tts.Tests.Specs;
+
+using Microsoft.Extensions.Logging;
+using GenWave.Core.Domain;
+using GenWave.Tts.Tests.Fakes;
+
+public static class FeatureBoothWritesForTwo
+{
+ // ── Shared fixtures ─────────────────────────────────────────────────────
+
+ static readonly PersonaCard HostCard = MakeCard(
+ "Neon Nightowl", "Neon Nightowl spins moody late-night sets deep into the small hours.");
+
+ static readonly PersonaCard NeighborCard = MakeCard(
+ "Daybreak Dana", "Daybreak Dana brings bright upbeat energy straight off the morning show.");
+
+ static readonly string WellFormedReply = string.Join('\n', new[]
+ {
+ $"{CrosstalkScriptParser.HostTag}: Hey, welcome back to the show.",
+ $"{CrosstalkScriptParser.NeighborTag}: Great to drop in tonight.",
+ $"{CrosstalkScriptParser.HostTag}: Always good to have you around.",
+ });
+
+ static PersonaCard MakeCard(string name, string soul) =>
+ new(PersonaCard.CurrentSchemaVersion, name, Tagline: "", soul, Quirks: [],
+ new VoiceSpec("kokoro", "af_heart", 1.0, "en"), EnergyDisposition: 0, Lore: [], Corrections: []);
+
+ static CrosstalkExchangeRequest Request() =>
+ new(HostCard, NeighborCard, "GenWave", ShowName: "Night Shift", Daypart: "late night",
+ StationLocalNow: DateTimeOffset.UtcNow);
+
+ ///
+ /// The one constructor arg list in this file (mirrors Story319_CopyFitsItsBreak's own
+ /// BuildWriterWithRingAndLogger idiom) — every other builder below is expressed in terms of
+ /// this, not a second copy of it.
+ ///
+ static (CrosstalkScriptWriter Writer, LlmCallRing Ring, CapturingLogger Logger,
+ TestOptionsMonitor CrosstalkMonitor) BuildWriterWithRingAndLogger(
+ string endpoint, int maxCopyChars = 450, int durationTargetSeconds = 25)
+ {
+ var ring = new LlmCallRing(new TestOptionsMonitor(new LlmOptions()));
+ var logger = new CapturingLogger();
+ var crosstalkMonitor = new TestOptionsMonitor(
+ new CrosstalkOptions { DurationTargetSeconds = durationTargetSeconds });
+ var writer = new CrosstalkScriptWriter(
+ new FakeHttpClientFactory(),
+ new TestOptionsMonitor(new LlmOptions
+ {
+ Endpoint = endpoint,
+ Model = "test-model",
+ TimeoutSeconds = 5,
+ MaxCopyChars = maxCopyChars,
+ }),
+ crosstalkMonitor,
+ ring,
+ new FakeDegradationModeReader(),
+ logger,
+ TimeProvider.System);
+ return (writer, ring, logger, crosstalkMonitor);
+ }
+
+ static CrosstalkScriptWriter BuildWriter(string endpoint, int maxCopyChars = 450, int durationTargetSeconds = 25) =>
+ BuildWriterWithRingAndLogger(endpoint, maxCopyChars, durationTargetSeconds).Writer;
+
+ static string ExtractSystemPrompt(string body)
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(body);
+ foreach (var message in doc.RootElement.GetProperty("messages").EnumerateArray())
+ {
+ if (message.GetProperty("role").GetString() == "system")
+ return message.GetProperty("content").GetString() ?? "";
+ }
+
+ return "";
+ }
+
+ static int ExtractMaxTokens(string body)
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(body);
+ return doc.RootElement.GetProperty("max_tokens").GetInt32();
+ }
+
+ static string ExtractUserContent(string body)
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(body);
+ foreach (var message in doc.RootElement.GetProperty("messages").EnumerateArray())
+ {
+ if (message.GetProperty("role").GetString() == "user")
+ return message.GetProperty("content").GetString() ?? "";
+ }
+
+ return "";
+ }
+
+ // ── HAPPY PATH ──────────────────────────────────────────────────────────
+
+ public sealed class ScenarioOneCallWholeExchange : IAsyncLifetime
+ {
+ const int MaxCopyChars = 300;
+
+ MockCompletionsServer mock = null!;
+ string wireSystemPrompt = "";
+ int wireMaxTokens;
+
+ public async Task InitializeAsync()
+ {
+ // Given the host and neighbor persona cards plus show/daypart/time hooks...
+ mock = await MockCompletionsServer.StartAsync();
+ mock.ReplyContent = WellFormedReply;
+ var writer = BuildWriter(mock.BaseUri.ToString(), MaxCopyChars);
+
+ // When the writer requests an exchange...
+ await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ wireSystemPrompt = ExtractSystemPrompt(mock.Requests[0].Body);
+ wireMaxTokens = ExtractMaxTokens(mock.Requests[0].Body);
+ }
+
+ public async Task DisposeAsync() => await mock.DisposeAsync();
+
+ [Fact]
+ public void Exactly_one_completion_is_issued_per_exchange() =>
+ // Then ONE completion request leaves — never per-turn calls.
+ Assert.Equal(1, mock.RequestCount);
+
+ [Fact]
+ public void The_request_carries_both_persona_cards()
+ {
+ Assert.Contains(HostCard.Soul, wireSystemPrompt, StringComparison.Ordinal);
+ Assert.Contains(NeighborCard.Soul, wireSystemPrompt, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void The_request_carries_the_F123_derived_generation_cap() =>
+ // The one-knob discipline extends: the cap derives from Llm:MaxCopyChars, no second
+ // operator setting for banter.
+ Assert.Equal(LlmCopyWriter.DeriveMaxTokens(MaxCopyChars), wireMaxTokens);
+ }
+
+ public sealed class ScenarioTheScriptParsesStrictly : IAsyncLifetime
+ {
+ // A NEIGHBOR interjection immediately after another NEIGHBOR line — plain alternation would
+ // reject this, but the interjection marker is the one sanctioned exception (SPEC F127.4).
+ static readonly string Reply = string.Join('\n', new[]
+ {
+ $"{CrosstalkScriptParser.HostTag}: Hey, welcome back to the show.",
+ $"{CrosstalkScriptParser.NeighborTag}: Great to drop in tonight.",
+ $"{CrosstalkScriptParser.NeighborTag} {CrosstalkScriptParser.InterjectionMarker}: Wait, I have to say —",
+ $"{CrosstalkScriptParser.HostTag}: Go right ahead then.",
+ });
+
+ MockCompletionsServer mock = null!;
+ CrosstalkWriteResult result = null!;
+
+ public async Task InitializeAsync()
+ {
+ mock = await MockCompletionsServer.StartAsync();
+ mock.ReplyContent = Reply;
+ var writer = BuildWriter(mock.BaseUri.ToString());
+
+ // When the script is parsed...
+ result = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+ }
+
+ public async Task DisposeAsync() => await mock.DisposeAsync();
+
+ [Fact]
+ public void A_well_formed_response_yields_three_to_eight_speaker_tagged_lines()
+ {
+ var accepted = Assert.IsType(result);
+ Assert.InRange(accepted.Script.Lines.Count, CrosstalkScriptParser.MinLines, CrosstalkScriptParser.MaxLines);
+ }
+
+ [Fact]
+ public void Both_speakers_are_present_in_an_accepted_script()
+ {
+ var accepted = Assert.IsType(result);
+ Assert.Contains(accepted.Script.Lines, line => line.Speaker == CrosstalkSpeaker.Host);
+ Assert.Contains(accepted.Script.Lines, line => line.Speaker == CrosstalkSpeaker.Neighbor);
+ }
+
+ [Fact]
+ public void Alternation_holds_outside_interjection_marked_lines()
+ {
+ // The reply's third line breaks strict adjacent alternation (NEIGHBOR follows NEIGHBOR)
+ // ONLY because it is marked as an interjection — proving the parser's one sanctioned
+ // exception is what let it through, not a broken alternation check.
+ var accepted = Assert.IsType(result);
+ Assert.True(accepted.Script.Lines[2].IsInterjection);
+ Assert.Equal(accepted.Script.Lines[1].Speaker, accepted.Script.Lines[2].Speaker);
+ }
+ }
+
+ public sealed class ScenarioPerLineHygieneWithoutTrimming : IAsyncLifetime
+ {
+ MockCompletionsServer mock = null!;
+
+ public async Task InitializeAsync() => mock = await MockCompletionsServer.StartAsync();
+ public async Task DisposeAsync() => await mock.DisposeAsync();
+
+ [Fact]
+ public async Task Every_accepted_line_has_cleared_the_standing_copy_cleanup()
+ {
+ // Given a parsed script whose lines carry the same hygiene hazards an ordinary blurb
+ // does (wrapping quotes, a bracketed stage direction)...
+ mock.ReplyContent = string.Join('\n', new[]
+ {
+ $"{CrosstalkScriptParser.HostTag}: \"Welcome back to the show.\"",
+ $"{CrosstalkScriptParser.NeighborTag}: *laughs* Great to be here tonight.",
+ $"{CrosstalkScriptParser.HostTag}: Always a pleasure to have you.",
+ });
+ var writer = BuildWriter(mock.BaseUri.ToString());
+
+ // When validation runs...
+ var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ // Then every accepted line has cleared the standing copy cleanup.
+ var accepted = Assert.IsType(result);
+ Assert.Equal("Welcome back to the show.", accepted.Script.Lines[0].Text);
+ Assert.Equal("Great to be here tonight.", accepted.Script.Lines[1].Text);
+ }
+
+ [Fact]
+ public async Task No_line_is_ever_trimmed()
+ {
+ // A cut dialogue line breaks the reaction to it — over-budget rejects the WHOLE exchange
+ // (sad path), it never salvages a line (F123.2's trim deliberately does NOT extend here).
+ mock.ReplyContent = string.Join('\n', new[]
+ {
+ $"{CrosstalkScriptParser.HostTag}: This line is written to run well past the tiny " +
+ "configured per-line character budget for this fact.",
+ $"{CrosstalkScriptParser.NeighborTag}: Short reply.",
+ $"{CrosstalkScriptParser.HostTag}: Another short one.",
+ });
+ var writer = BuildWriter(mock.BaseUri.ToString(), maxCopyChars: 20);
+
+ var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ // Never a truncated Accepted — the whole exchange is discarded instead.
+ Assert.IsType(result);
+ }
+ }
+
+ public sealed class ScenarioTheExchangeFitsItsMoment : IAsyncLifetime
+ {
+ MockCompletionsServer mock = null!;
+
+ public async Task InitializeAsync() => mock = await MockCompletionsServer.StartAsync();
+ public async Task DisposeAsync() => await mock.DisposeAsync();
+
+ [Fact]
+ public async Task A_script_under_the_duration_target_is_accepted()
+ {
+ // Given a validated script well under the 25s default (three short lines)...
+ mock.ReplyContent = WellFormedReply;
+ var writer = BuildWriter(mock.BaseUri.ToString());
+
+ // When the spoken-duration estimate is computed...
+ var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public async Task The_duration_target_is_live_editable_with_a_25s_default()
+ {
+ // Given the shipped default (SPEC F127.4) — 200 chars / 15 chars-per-sec ~= 13.3s, which
+ // fits comfortably under it.
+ Assert.Equal(25, new CrosstalkOptions().DurationTargetSeconds);
+
+ mock.ReplyContent = string.Join('\n', new[]
+ {
+ $"{CrosstalkScriptParser.HostTag}: {new string('a', 70)}",
+ $"{CrosstalkScriptParser.NeighborTag}: {new string('b', 70)}",
+ $"{CrosstalkScriptParser.HostTag}: {new string('c', 60)}",
+ });
+ var (writer, _, _, crosstalkMonitor) = BuildWriterWithRingAndLogger(mock.BaseUri.ToString());
+
+ var underDefault = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+ Assert.IsType(underDefault);
+
+ // When the live setting drops below that same script's estimate, with NO writer
+ // rebuild...
+ crosstalkMonitor.CurrentValue = new CrosstalkOptions { DurationTargetSeconds = 10 };
+ var overLoweredTarget = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ // Then the very next attempt reflects the live edit.
+ Assert.IsType(overLoweredTarget);
+ }
+ }
+
+ public static class ScenarioGenerationIsVisible
+ {
+ [Fact]
+ public static async Task The_call_appears_in_the_llm_ring_under_its_own_kind()
+ {
+ // Given any generation attempt...
+ await using var mock = await MockCompletionsServer.StartAsync();
+ mock.ReplyContent = WellFormedReply;
+ var (writer, ring, _, _) = BuildWriterWithRingAndLogger(mock.BaseUri.ToString());
+
+ await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ // When /api/llm-calls is read (the ring it's built from)...
+ var record = Assert.Single(ring.Snapshot());
+
+ // Then the call appears under its own kind.
+ Assert.Equal(LlmCallKind.Crosstalk, record.Kind);
+ }
+
+ // T282 review finding (F2b): mutation-proven — deleting the discard path's own
+ // callRing.Record(...) call left the suite green, since nothing previously asserted a
+ // DISCARDED attempt is visible to the ring at all. A discard must be just as visible as an
+ // accept (SPEC F127.11 — "why was there no banter" has to be answerable from the ring for
+ // a reject, not only for a success).
+ [Fact]
+ public static async Task A_discarded_attempt_also_appears_in_the_ring()
+ {
+ // Given a response that fails validation (no recognizable speaker tags at all)...
+ await using var mock = await MockCompletionsServer.StartAsync();
+ mock.ReplyContent = "Hey there, welcome back!\nGreat to see you too!\nLet's get into it.";
+ var (writer, ring, _, _) = BuildWriterWithRingAndLogger(mock.BaseUri.ToString());
+
+ var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ // Then the ring records the discard — Rejected outcome, the Crosstalk kind, and the
+ // SAME reason string returned to the caller carried as StatusDetail — never silence.
+ var discarded = Assert.IsType(result);
+ var record = Assert.Single(ring.Snapshot());
+ Assert.Equal(LlmCallOutcome.Rejected, record.Outcome);
+ Assert.Equal(LlmCallKind.Crosstalk, record.Kind);
+ Assert.Equal(discarded.Reason, record.StatusDetail);
+ }
+ }
+
+ // ── SAD PATH ────────────────────────────────────────────────────────────
+
+ public sealed class ScenarioAnyValidationFailureDiscardsSilently : IAsyncLifetime
+ {
+ MockCompletionsServer mock = null!;
+
+ public async Task InitializeAsync() => mock = await MockCompletionsServer.StartAsync();
+ public async Task DisposeAsync() => await mock.DisposeAsync();
+
+ [Fact]
+ public async Task A_malformed_response_produces_no_exchange_and_one_reason_line()
+ {
+ // Given a response failing parse (no recognizable speaker tags at all)...
+ mock.ReplyContent = "Hey there, welcome back!\nGreat to see you too!\nLet's get into it.";
+ var (writer, _, logger, _) = BuildWriterWithRingAndLogger(mock.BaseUri.ToString());
+
+ // When the writer completes...
+ var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ // Then no exchange is produced, and exactly one Information line names the reason — no
+ // template rung, no salvage, and never a WARN (banter is optional color, a miss is not
+ // an outage).
+ Assert.IsType(result);
+ Assert.Single(logger.Entries, entry => entry.Level == LogLevel.Information);
+ Assert.Empty(logger.Warnings);
+ }
+
+ [Fact]
+ public async Task An_over_budget_line_rejects_the_whole_exchange()
+ {
+ mock.ReplyContent = string.Join('\n', new[]
+ {
+ $"{CrosstalkScriptParser.HostTag}: A line intentionally longer than the tiny " +
+ "per-line budget configured for this fact.",
+ $"{CrosstalkScriptParser.NeighborTag}: Short.",
+ $"{CrosstalkScriptParser.HostTag}: Also short.",
+ });
+ var writer = BuildWriter(mock.BaseUri.ToString(), maxCopyChars: 15);
+
+ var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ var discarded = Assert.IsType(result);
+ Assert.Contains("per-line budget", discarded.Reason, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task An_over_duration_script_rejects_whole()
+ {
+ mock.ReplyContent = string.Join('\n', new[]
+ {
+ $"{CrosstalkScriptParser.HostTag}: {new string('a', 70)}",
+ $"{CrosstalkScriptParser.NeighborTag}: {new string('b', 70)}",
+ $"{CrosstalkScriptParser.HostTag}: {new string('c', 60)}",
+ });
+ var writer = BuildWriter(mock.BaseUri.ToString(), durationTargetSeconds: 1);
+
+ var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ var discarded = Assert.IsType(result);
+ Assert.Contains("exceeds", discarded.Reason, StringComparison.Ordinal);
+ }
+
+ // T282 review finding (F2a): mutation-proven — deleting the both-speakers-present guards
+ // left the suite green. The reachable hole: a reply where every line AFTER the first is
+ // marked as an interjection (SPEC F127.4's one sanctioned exception to strict alternation)
+ // never trips the alternation loop below either, since IsInterjection short-circuits every
+ // adjacent pair — so with the guards gone, a one-voice "HOST:"/all-"HOST (interjects):"
+ // script would validate cleanly. Only "both speakers must be present" catches it.
+ [Fact]
+ public async Task A_single_speaker_all_interjection_reply_is_discarded()
+ {
+ mock.ReplyContent = string.Join('\n', new[]
+ {
+ $"{CrosstalkScriptParser.HostTag}: Hey there, just me tonight.",
+ $"{CrosstalkScriptParser.HostTag} {CrosstalkScriptParser.InterjectionMarker}: Still me.",
+ $"{CrosstalkScriptParser.HostTag} {CrosstalkScriptParser.InterjectionMarker}: Also me.",
+ });
+ var writer = BuildWriter(mock.BaseUri.ToString());
+
+ var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ var discarded = Assert.IsType(result);
+ Assert.Contains(CrosstalkScriptParser.NeighborTag, discarded.Reason, StringComparison.Ordinal);
+ }
+ }
+
+ // T282 review finding (F3, gh-#424 class one seam over): a completion the backend cuts short
+ // at its own max_tokens cap leaves a truncated last line that can still PARSE cleanly (a
+ // chopped sentence still matches the HOST:/NEIGHBOR: line shape) and would otherwise air
+ // mid-word — finish_reason is the OpenAI/ollama-compatible signal that catches it BEFORE Parse
+ // ever runs.
+ public sealed class ScenarioATruncatedCompletionNeverAirs : IAsyncLifetime
+ {
+ MockCompletionsServer mock = null!;
+
+ public async Task InitializeAsync() => mock = await MockCompletionsServer.StartAsync();
+ public async Task DisposeAsync() => await mock.DisposeAsync();
+
+ [Fact]
+ public async Task A_completion_capped_by_max_tokens_is_discarded_even_though_it_would_otherwise_parse()
+ {
+ // Given a well-formed-LOOKING reply the backend flags as cut short by its own token cap...
+ mock.ReplyContent = WellFormedReply;
+ mock.ReplyFinishReason = "length";
+ var writer = BuildWriter(mock.BaseUri.ToString());
+
+ // When the writer completes...
+ var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ // Then the whole exchange is discarded — never aired truncated.
+ var discarded = Assert.IsType(result);
+ Assert.Contains("length", discarded.Reason, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task A_completion_that_finished_naturally_is_not_discarded_for_that_reason()
+ {
+ // Given the SAME well-formed reply, this time flagged as having finished naturally
+ // (the mock's own default) — a "stop" finish_reason must never trip this check.
+ mock.ReplyContent = WellFormedReply;
+ mock.ReplyFinishReason = "stop";
+ var writer = BuildWriter(mock.BaseUri.ToString());
+
+ var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None);
+
+ Assert.IsType(result);
+ }
+ }
+
+ public static class ScenarioTheCurrentTrackIsStructurallyUnknowable
+ {
+ [Fact]
+ public static void The_prompt_contains_no_current_track_reference()
+ {
+ // Given prompt assembly for an exchange — CrosstalkExchangeRequest is the ONLY input
+ // CrosstalkPromptBuilder ever reads from, and it carries no MediaItem/track-shaped member
+ // at all, by construction (mirrors Story228_RequestShoutOut's own reflection proof one
+ // seam over).
+ var properties = typeof(CrosstalkExchangeRequest).GetProperties();
+
+ Assert.DoesNotContain(properties, p => p.PropertyType == typeof(MediaItem));
+ Assert.DoesNotContain(properties, p => p.Name.Contains("Track", StringComparison.OrdinalIgnoreCase));
+ }
+ }
+
+ // T282 review finding (F4): CrosstalkExchangeRequest's ShowName/Daypart are operator-editable
+ // hooks with no length constraint of their own (the db show name column is unbounded text) —
+ // every LlmPromptBuilder counterpart truncates text like this before it reaches a prompt (e.g.
+ // BuildShowLine's own showName truncation), and this builder must do the same.
+ public sealed class ScenarioOperatorHooksAreCapped : IAsyncLifetime
+ {
+ MockCompletionsServer mock = null!;
+
+ public async Task InitializeAsync() => mock = await MockCompletionsServer.StartAsync();
+ public async Task DisposeAsync() => await mock.DisposeAsync();
+
+ [Fact]
+ public async Task An_oversized_show_name_reaches_the_prompt_truncated()
+ {
+ // Given a ShowName far past the house 4000-char cap...
+ mock.ReplyContent = WellFormedReply;
+ var writer = BuildWriter(mock.BaseUri.ToString());
+ var oversizedShowName = new string('a', 5000);
+ var request = Request() with { ShowName = oversizedShowName };
+
+ // When the writer requests an exchange...
+ await writer.WriteExchangeAsync(request, CancellationToken.None);
+
+ // Then the prompt carries the truncated form, never the full 5000 chars.
+ var userContent = ExtractUserContent(mock.Requests[0].Body);
+ Assert.DoesNotContain(oversizedShowName, userContent, StringComparison.Ordinal);
+ Assert.Contains(new string('a', 4000), userContent, StringComparison.Ordinal);
+ }
+ }
+}
diff --git a/tests/GenWave.Tts.Tests/Specs/Story327_TwoVoicesOneClip.cs b/tests/GenWave.Tts.Tests/Specs/Story327_TwoVoicesOneClip.cs
new file mode 100644
index 0000000..ed8df8a
--- /dev/null
+++ b/tests/GenWave.Tts.Tests/Specs/Story327_TwoVoicesOneClip.cs
@@ -0,0 +1,102 @@
+// STORY-327 — Two voices, one clip (gh-#385 · SPEC F127.5/.6 · PLAN VQ-i, T284)
+//
+// BDD specification — xUnit, pending until /build-loop turns them green. The craft target
+// (Dean's word): reaction lines and interruption timing — the walkie-talkie turn-taking
+// tell is what assembly exists to kill. Each line renders through the ONE funnel with ITS
+// speaker's TtsRenderContext (F97.6 carriage, per line); ffmpeg assembles ONE asset the
+// playout pipeline treats like any segment — no engine change, no multi-source mixing at
+// air time. F99 extends per line: both voices or nobody. One assertion per Fact; happy
+// first; sad segregated. The T288 wire acceptance is a production check, not here.
+// ⛔ Gated behind T283's paper-audition go.
+
+namespace GenWave.Tts.Tests.Specs;
+
+public static class FeatureTwoVoicesOneClip
+{
+ // ── HAPPY PATH ──────────────────────────────────────────────────────────
+
+ public static class ScenarioEveryLineRidesItsOwnSpeakersContext
+ {
+ [Fact(Skip = "Pending T284 — see docs/PLAN.md")]
+ public static void A_hosts_line_renders_with_the_hosts_rules_and_pace()
+ {
+ // Given a validated script
+ // When the lines render
+ // Then each render's TtsRenderContext carries THAT speaker's resolved
+ // pronunciation rules and pace — never the other's
+ Assert.Fail("pending T284");
+ }
+
+ [Fact(Skip = "Pending T284 — see docs/PLAN.md")]
+ public static void A_neighbors_line_renders_with_the_neighbors_rules_and_pace()
+ {
+ Assert.Fail("pending T284");
+ }
+ }
+
+ public static class ScenarioAssemblyBreathes
+ {
+ [Fact(Skip = "Pending T284 — see docs/PLAN.md")]
+ public static void Assembly_produces_exactly_one_audio_asset()
+ {
+ Assert.Fail("pending T284");
+ }
+
+ [Fact(Skip = "Pending T284 — see docs/PLAN.md")]
+ public static void Inter_line_gaps_are_jittered_within_the_bounded_range()
+ {
+ // ~0.2–0.8s, seeded per exchange — uniform gaps are the second-biggest
+ // TTS-dialogue tell.
+ Assert.Fail("pending T284");
+ }
+
+ [Fact(Skip = "Pending T284 — see docs/PLAN.md")]
+ public static void An_interjection_overlaps_the_prior_lines_tail_by_a_bounded_offset()
+ {
+ Assert.Fail("pending T284");
+ }
+ }
+
+ public static class ScenarioTheClipIsAFirstClassSegment
+ {
+ [Fact(Skip = "Pending T284 — see docs/PLAN.md")]
+ public static void The_assembled_clip_is_loudness_measured_like_any_segment()
+ {
+ Assert.Fail("pending T284");
+ }
+ }
+
+ // ── SAD PATH ────────────────────────────────────────────────────────────
+
+ public static class ScenarioBothVoicesOrNobody
+ {
+ [Fact(Skip = "Pending T284 — see docs/PLAN.md")]
+ public static void One_line_failing_the_right_voice_bar_discards_the_whole_exchange()
+ {
+ // F99 per line — no other voice ever speaks a persona's line, and no
+ // single-voice salvage of a two-voice exchange exists.
+ Assert.Fail("pending T284");
+ }
+
+ [Fact(Skip = "Pending T284 — see docs/PLAN.md")]
+ public static void A_discarded_exchange_leaves_no_asset_behind()
+ {
+ Assert.Fail("pending T284");
+ }
+ }
+
+ public static class ScenarioTheEstimateLied
+ {
+ [Fact(Skip = "Pending T284 — see docs/PLAN.md")]
+ public static void A_clip_past_one_point_five_times_the_target_is_discarded()
+ {
+ Assert.Fail("pending T284");
+ }
+
+ [Fact(Skip = "Pending T284 — see docs/PLAN.md")]
+ public static void The_discard_logs_both_the_estimated_and_actual_durations()
+ {
+ Assert.Fail("pending T284");
+ }
+ }
+}
|