refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910)

Relands **#2467** (extract `buzz-voice` crate) and **#3208** (Pocket
voice settings) onto main, after #3266 and #3180 merged.

## Why a fresh PR
The repo is squash-only with delete-branch-on-merge. Squashing #3266
deleted `jtennant/pocket-tts-2026-04`, which was #2467's base — GitHub
auto-closed #2467 and it cannot be reopened. Squash merges also sever
ancestry, so GitHub's natural merge-base reports phantom conflicts for
the whole remaining stack.

## Content provenance
- Byte-identical to the blessed `jt/buzz-voice-refactor` branch
(`93029c577`, tree `6729e0eff` — reviewed by Dawn (#2467) and Max
(#3208) at exact heads) **except** the three files where #3180 and #3208
genuinely interact.
- Three-file resolution (union of both sides):
- `huddle/mod.rs` — #3180's pipeline re-exports + #3208's
`agent_tts_routing` imports.
- `huddle/state.rs` — `reset_preserving_generation` preserves both
`huddle_generation` (#3180) and `tts_enabled` (#3208); test sets merged
into one `tests` module.
- `desktop/src/testing/e2eBridge.ts` — both switch arms kept; no
duplicate case labels.

## Verification at cf32dacd7
- `cargo test` (desktop/src-tauri, pinned 1.95.0): **2047 + 3 pass / 0
fail** (14 ignored: 8 keychain, 4 real_relay, 2 flag-gated)
- `cargo clippy --all-targets -- -D warnings`: clean; `cargo fmt
--check`: clean
- `cargo check --workspace` (root, includes new `buzz-voice` member):
clean; `cargo test -p buzz-voice`: 5/0
- `pnpm test`: **3885 / 0**; `tsc --noEmit`: clean; lint: clean

The 3180×3208 interaction resolution is getting an independent team
re-review before merge.

Buzz channel: buzz-desktop-voice `fd5fb402-b651-4238-89b1-bb3e2fa4dc96`,
thread `b4798ecc`.

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
This commit is contained in:
Tyler
2026-07-31 09:41:09 -04:00
committed by GitHub
co-authored by npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent 689617af7a
commit 61ba9dfaa0
63 changed files with 5019 additions and 698 deletions
@@ -0,0 +1,247 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
classifySpeakableAgentText,
createInitialMembershipGate,
createLatestStateGate,
createOrderedSpeaker,
routeLiveAgentText,
} from "./ttsLiveMessages.ts";
const agents = new Set(["agent"]);
const CHANNEL = "active-huddle";
const base = {
id: "1",
kind: 9,
pubkey: "agent",
content: "Hello there",
tags: [["h", CHANNEL]],
};
const speakableText = (event, selfPubkey = "human") =>
classifySpeakableAgentText(event, agents, selfPubkey, CHANNEL).text;
test("speaks only new agent-authored text message events", () => {
assert.equal(speakableText(base), "Hello there");
assert.equal(
speakableText({ ...base, kind: 40002 }),
"Hello there",
"managed stream-message-v2 replies are spoken",
);
assert.equal(
speakableText({ ...base, kind: 7 }),
null,
"reactions and other event kinds are excluded",
);
assert.equal(
speakableText({ ...base, kind: 10 }),
null,
"edits and status events are excluded",
);
assert.equal(
speakableText({ ...base, pubkey: "human" }),
null,
"human-authored messages are excluded",
);
assert.equal(
speakableText({ ...base, content: " " }),
null,
"empty and non-text content are excluded",
);
assert.equal(
speakableText({ ...base, content: "K" }),
"K",
"one-character agent text remains speakable",
);
assert.equal(
speakableText({ ...base, content: "[System] tool started" }),
null,
"legacy system rows are excluded",
);
assert.equal(
speakableText({ ...base, tags: [["h", "another-huddle"]] }),
null,
"messages for another huddle are excluded",
);
});
test("routes managed stream-message-v2 through membership and enabled ordering", async () => {
const invoked = [];
const speaker = createOrderedSpeaker(async (text, routeId) => {
invoked.push({ text, routeId });
}, assert.fail);
assert.equal(
routeLiveAgentText(
{ ...base, kind: 40002 },
agents,
"human",
CHANNEL,
77,
speaker.enqueue,
),
"queued",
);
assert.equal(
routeLiveAgentText(
{ ...base, kind: 7 },
agents,
"human",
CHANNEL,
78,
speaker.enqueue,
),
"unsupported_kind",
);
assert.equal(
routeLiveAgentText(
{ ...base, tags: [["h", "wrong"]] },
agents,
"human",
CHANNEL,
79,
speaker.enqueue,
),
"h_tag_mismatch",
);
assert.equal(
routeLiveAgentText(
{ ...base, pubkey: "human" },
agents,
"human",
CHANNEL,
80,
speaker.enqueue,
),
"author_not_agent",
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(invoked, [{ text: "Hello there", routeId: 77 }]);
});
test("strips attachment markup and skips attachment-only events", () => {
const url = "https://cdn.example/voice.png";
const tags = [...base.tags, ["imeta", `url ${url}`, "m image/png"]];
assert.equal(
speakableText({ ...base, content: `![image](${url})`, tags }),
null,
);
assert.equal(
speakableText({
...base,
content: `Here is the diagram.\n\n![image](${url})`,
tags,
}),
"Here is the diagram.",
);
assert.equal(
speakableText({ ...base, content: `||\n![image](${url})\n||`, tags }),
null,
);
});
test("queues agent messages in live thread arrival order", async () => {
const spoken = [];
let releaseFirst;
const firstBlocked = new Promise((resolve) => {
releaseFirst = resolve;
});
const speaker = createOrderedSpeaker(async (text, routeId) => {
if (text === "first") await firstBlocked;
spoken.push([text, routeId]);
}, assert.fail);
speaker.enqueue("first", 41);
speaker.enqueue("second", 42);
await Promise.resolve();
assert.deepEqual(spoken, []);
releaseFirst();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(spoken, [
["first", 41],
["second", 42],
]);
});
test("disabling cancels queued speech and rejects new messages until enabled", async () => {
const invoked = [];
const dropped = [];
let releaseFirst;
const firstBlocked = new Promise((resolve) => {
releaseFirst = resolve;
});
const speaker = createOrderedSpeaker(
async (text) => {
invoked.push(text);
if (text === "first") await firstBlocked;
},
assert.fail,
true,
(routeId, reason) => dropped.push([routeId, reason]),
);
speaker.enqueue("first", 51);
speaker.enqueue("queued-before-off", 52);
await Promise.resolve();
speaker.setEnabled(false);
speaker.enqueue("while-off");
releaseFirst();
await new Promise((resolve) => setTimeout(resolve, 0));
speaker.setEnabled(true);
speaker.enqueue("after-on");
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(invoked, ["first", "after-on"]);
assert.deepEqual(dropped, [[52, "disabled"]]);
});
test("does not speak before the native enabled state is known", async () => {
const invoked = [];
const speaker = createOrderedSpeaker(
async (text) => invoked.push(text),
assert.fail,
false,
);
speaker.enqueue("before-state");
await Promise.resolve();
speaker.setEnabled(true);
speaker.enqueue("after-state");
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(invoked, ["after-state"]);
});
test("a live TTS state event supersedes a delayed bootstrap result", () => {
const applied = [];
const gate = createLatestStateGate((enabled) => applied.push(enabled));
const applyBootstrap = gate.beginSnapshot();
gate.applyEvent(false);
applyBootstrap(true);
assert.deepEqual(applied, [false]);
});
test("buffers initial live events until membership resolves in order", () => {
const delivered = [];
const gate = createInitialMembershipGate((event) => delivered.push(event));
gate.push("first");
gate.push("second");
assert.deepEqual(delivered, []);
gate.succeed();
gate.push("third");
assert.deepEqual(delivered, ["first", "second", "third"]);
});
test("drops the initial buffer fail-closed when membership lookup fails", () => {
const delivered = [];
const dropped = [];
const gate = createInitialMembershipGate(
(event) => delivered.push(event),
(event) => dropped.push(event),
);
gate.push("unverified");
gate.fail();
gate.push("after-failure");
assert.deepEqual(delivered, ["after-failure"]);
assert.deepEqual(dropped, ["unverified"]);
});
@@ -0,0 +1,185 @@
import {
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
} from "../../../shared/constants/kinds.ts";
export type LiveTtsEvent = {
id: string;
kind: number;
pubkey: string;
content: string;
tags: string[][];
};
export type LiveTtsEligibility =
| { text: string; reason: null }
| {
text: null;
reason:
| "unsupported_kind"
| "h_tag_mismatch"
| "author_not_agent"
| "self_authored"
| "empty_or_system";
};
export type LiveTtsRouteResult =
| "queued"
| "disabled"
| Exclude<LiveTtsEligibility, { text: string }>["reason"];
function textWithoutAttachments(event: LiveTtsEvent): string {
const urls = new Set(
event.tags
.filter((tag) => tag[0] === "imeta")
.flatMap((tag) =>
tag
.slice(1)
.filter((field) => field.startsWith("url "))
.map((field) => field.slice(4)),
),
);
if (urls.size === 0) return event.content;
const withoutMedia = event.content
.split("\n")
.filter(
(line) => !Array.from(urls).some((url) => line.includes(`](${url})`)),
)
.join("\n");
return withoutMedia.replace(
/(^|\n)\s*\|\|\s*\n(?:\s*\n)*\s*\|\|\s*(?=\n|$)/gu,
"$1",
);
}
export function classifySpeakableAgentText(
event: LiveTtsEvent,
agentPubkeys: ReadonlySet<string>,
selfPubkey: string | null,
channelId: string,
): LiveTtsEligibility {
if (
event.kind !== KIND_STREAM_MESSAGE &&
event.kind !== KIND_STREAM_MESSAGE_V2
)
return { text: null, reason: "unsupported_kind" };
if (!event.tags.some((tag) => tag[0] === "h" && tag[1] === channelId))
return { text: null, reason: "h_tag_mismatch" };
if (!agentPubkeys.has(event.pubkey))
return { text: null, reason: "author_not_agent" };
if (event.pubkey === selfPubkey)
return { text: null, reason: "self_authored" };
const content = textWithoutAttachments(event).trim();
if (content.length === 0 || content.startsWith("[System]"))
return { text: null, reason: "empty_or_system" };
return { text: content, reason: null };
}
/** Classify and enqueue one live event through the production routing seam. */
export function routeLiveAgentText(
event: LiveTtsEvent,
agentPubkeys: ReadonlySet<string>,
selfPubkey: string | null,
channelId: string,
routeId: number,
enqueue: (text: string, routeId: number) => "queued" | "disabled",
): LiveTtsRouteResult {
const eligibility = classifySpeakableAgentText(
event,
agentPubkeys,
selfPubkey,
channelId,
);
if (eligibility.text === null) return eligibility.reason;
return enqueue(eligibility.text, routeId);
}
/**
* Serialize native speak calls so live messages enter the bounded Pocket queue
* in thread arrival order even when the bridge resolves calls asynchronously.
*/
export function createOrderedSpeaker(
speak: (text: string, routeId: number) => Promise<void>,
onError: (error: unknown) => void,
initiallyEnabled = true,
onDrop: (routeId: number, reason: "disabled") => void = () => {},
): {
enqueue: (text: string, routeId?: number) => "queued" | "disabled";
setEnabled: (enabled: boolean) => void;
} {
let tail = Promise.resolve();
let enabled = initiallyEnabled;
let generation = 0;
return {
enqueue(text, routeId = 0) {
if (!enabled) return "disabled";
const queuedGeneration = generation;
tail = tail
.then(() => {
if (!enabled || generation !== queuedGeneration) {
onDrop(routeId, "disabled");
return;
}
return speak(text, routeId);
})
.catch(onError);
return "queued";
},
setEnabled(nextEnabled) {
if (!nextEnabled) generation += 1;
enabled = nextEnabled;
},
};
}
/** Ensure a delayed bootstrap snapshot cannot overwrite a newer live event. */
export function createLatestStateGate<T>(apply: (value: T) => void): {
applyEvent: (value: T) => void;
beginSnapshot: () => (value: T) => void;
} {
let revision = 0;
return {
applyEvent(value) {
revision += 1;
apply(value);
},
beginSnapshot() {
const snapshotRevision = revision;
return (value) => {
if (revision === snapshotRevision) apply(value);
};
},
};
}
/** Hold live events until the first authoritative agent-membership lookup. */
export function createInitialMembershipGate<T>(
deliver: (event: T) => void,
drop: (event: T) => void = () => {},
): {
push: (event: T) => void;
succeed: () => void;
fail: () => void;
} {
let settled = false;
let pending: T[] = [];
return {
push(event) {
if (settled) deliver(event);
else pending.push(event);
},
succeed() {
if (settled) return;
settled = true;
const buffered = pending;
pending = [];
for (const event of buffered) deliver(event);
},
fail() {
settled = true;
const dropped = pending;
pending = [];
for (const event of dropped) drop(event);
},
};
}
@@ -1,13 +1,28 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import * as React from "react";
import { buildHuddleTtsLiveFilter } from "@/shared/api/relayChannelFilters";
import { relayClient } from "@/shared/api/relayClient";
import {
createInitialMembershipGate,
createLatestStateGate,
createOrderedSpeaker,
routeLiveAgentText,
} from "./ttsLiveMessages";
const AGENT_PUBKEY_REFRESH_INTERVAL_MS = 30_000;
let nextTtsRouteId = 1;
function allocateTtsRouteId(): number {
const routeId = nextTtsRouteId;
nextTtsRouteId += 1;
return routeId;
}
/**
* Subscribe to agent TTS messages on the ephemeral huddle channel.
* Pipes agent kind:9 messages to `speak_agent_message` on the Rust backend.
* Pipes new agent message events to `speak_agent_message` on the Rust backend.
*
* Extracted from HuddleContext to keep file sizes manageable.
*/
@@ -20,6 +35,8 @@ export function useTtsSubscription(
let disposed = false;
let cleanup: (() => void) | null = null;
let unlistenHuddleState: (() => void) | null = null;
let ttsStateKnown = false;
// ── Agent identity (authoritative, fail-closed) ───────────────────────
//
@@ -33,43 +50,152 @@ export function useTtsSubscription(
let agentsLoaded = false;
const agentPubkeys = new Set<string>();
async function loadAgentPubkeys() {
const speakInOrder = createOrderedSpeaker(
async (text, routeId) => {
if (!disposed) {
console.debug(
`[huddle] tts stage=invoke status=attempted route_id=${routeId}`,
);
try {
await invoke("speak_agent_message", { text, routeId });
console.debug(
`[huddle] tts stage=invoke status=accepted route_id=${routeId}`,
);
} catch (error) {
console.warn(
`[huddle] tts stage=invoke status=failed reason=native_error route_id=${routeId}`,
);
throw error;
}
}
},
() => {},
false,
(routeId, reason) => {
console.debug(
`[huddle] tts stage=queue status=dropped reason=${reason} route_id=${routeId}`,
);
},
);
const deliver = ({
event,
routeId,
}: {
event: Parameters<typeof routeLiveAgentText>[0];
routeId: number;
}) => {
if (disposed) return;
if (!agentsLoaded) {
console.debug(
`[huddle] tts stage=eligibility status=rejected reason=membership_unavailable route_id=${routeId}`,
);
return;
}
const result = routeLiveAgentText(
event,
agentPubkeys,
selfPubkeyRef.current,
ephemeralChannelId,
routeId,
speakInOrder.enqueue,
);
if (result === "queued") {
console.debug(
`[huddle] tts stage=eligibility status=accepted route_id=${routeId}`,
);
} else {
const reason =
result === "disabled" && !ttsStateKnown
? "tts_state_unknown"
: result;
console.debug(
`[huddle] tts stage=eligibility status=rejected reason=${reason} route_id=${routeId}`,
);
}
};
const initialMembershipGate = createInitialMembershipGate(
deliver,
({ routeId }) => {
console.debug(
`[huddle] tts stage=eligibility status=rejected reason=membership_unavailable route_id=${routeId}`,
);
},
);
async function loadAgentPubkeys(initial = false) {
try {
const pubkeys = await invoke<string[]>("get_huddle_agent_pubkeys");
if (disposed) return;
agentPubkeys.clear();
for (const pk of pubkeys) agentPubkeys.add(pk);
agentsLoaded = true;
if (initial) {
initialMembershipGate.succeed();
}
} catch (e) {
// Fail-closed on ALL failures, including refresh after prior success.
// Clear the set and mark as not loaded — TTS goes mute until the
// next successful refresh. Stale membership must never authorize speech.
agentPubkeys.clear();
agentsLoaded = false;
if (initial) {
initialMembershipGate.fail();
}
console.error("[huddle] Failed to load agent pubkeys:", e);
}
}
// Initial load + periodic refresh (catches mid-huddle agent additions).
void loadAgentPubkeys();
void loadAgentPubkeys(true);
const agentRefreshId = window.setInterval(() => {
void loadAgentPubkeys();
}, AGENT_PUBKEY_REFRESH_INTERVAL_MS);
// Install the state listener before requesting a snapshot. If a newer
// event arrives while IPC is pending, it supersedes the stale snapshot.
const ttsStateGate = createLatestStateGate<{ tts_enabled: boolean }>(
(state) => {
if (!disposed) {
ttsStateKnown = true;
speakInOrder.setEnabled(state.tts_enabled);
}
},
);
void listen<{ tts_enabled: boolean }>("huddle-state-changed", (event) => {
if (!disposed) ttsStateGate.applyEvent(event.payload);
})
.then((unlisten) => {
if (disposed) {
unlisten();
return;
}
unlistenHuddleState = unlisten;
const applyBootstrap = ttsStateGate.beginSnapshot();
void invoke<{ tts_enabled: boolean }>("get_huddle_state")
.then((state) => {
if (!disposed) applyBootstrap(state);
})
.catch((err) => {
console.warn("[huddle] Failed to load TTS state:", err);
});
})
.catch((err) => {
speakInOrder.setEnabled(false);
console.warn("[huddle] Failed to listen for TTS state:", err);
});
// ── Live-only subscription ───────────────────────────────────────────
// subscribeToChannelLive uses `since: now` — the relay never sends
// historical backlog. Every event delivered is a live message.
// A limit:0 subscription receives future message fan-out while the relay
// returns no stored rows, including pre-join rows from the current second.
// Event-ID dedup handles reconnect replay (same event arriving twice).
const seenEventIds = new Set<string>();
const seenOrder: string[] = [];
const MAX_SEEN_EVENTS = 5000;
relayClient
.subscribeToChannelLive(ephemeralChannelId, (event) => {
.subscribeLive(buildHuddleTtsLiveFilter(ephemeralChannelId), (event) => {
if (disposed) return;
// Defense-in-depth: subscription already filters to kind:9 only.
if (event.kind !== 9) return;
// Dedup by event ID (covers reconnect replay).
// Dedup by event ID if a relay repeats live fan-out.
if (seenEventIds.has(event.id)) return;
seenEventIds.add(event.id);
seenOrder.push(event.id);
@@ -78,20 +204,15 @@ export function useTtsSubscription(
if (oldest !== undefined) seenEventIds.delete(oldest);
}
// Fail-closed: don't speak until agent list is loaded.
if (!agentsLoaded) return;
// Only speak agent messages — skip human STT transcripts.
if (!agentPubkeys.has(event.pubkey)) return;
if (event.pubkey === selfPubkeyRef.current) return;
if (event.content.trim().length <= 1) return;
// Legacy: skip [System]-prefixed messages from before kind:48106.
if (event.content.startsWith("[System]")) return;
invoke("speak_agent_message", { text: event.content }).catch((err) => {
console.warn(
"[huddle] TTS speak failed (backpressure or pipeline unavailable):",
err,
// Preserve arrival order while the initial authoritative membership
// lookup is pending. A failed lookup clears this buffer fail-closed.
const routeId = allocateTtsRouteId();
if (!agentsLoaded) {
console.debug(
`[huddle] tts stage=eligibility status=deferred reason=membership_unavailable route_id=${routeId}`,
);
});
}
initialMembershipGate.push({ event, routeId });
})
.then((dispose) => {
if (disposed) {
@@ -106,7 +227,9 @@ export function useTtsSubscription(
return () => {
disposed = true;
speakInOrder.setEnabled(false);
cleanup?.();
unlistenHuddleState?.();
window.clearInterval(agentRefreshId);
};
}, [ephemeralChannelId, selfPubkeyRef]);
@@ -21,6 +21,7 @@ import {
SunMoon,
Ticket,
UserRound,
Volume2,
type LucideIcon,
} from "lucide-react";
import type {
@@ -83,10 +84,12 @@ import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import { ProfileSettingsCard } from "./ProfileSettingsCard";
import { UpdateChecker } from "../UpdateChecker";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import { VoiceSettingsCard } from "./VoiceSettingsCard";
export type SettingsSection =
| "profile"
| "notifications"
| "voice"
| "experimental"
| "agents"
| "channel-templates"
@@ -106,6 +109,7 @@ export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
"profile",
"notifications",
"voice",
"experimental",
"agents",
"channel-templates",
@@ -167,6 +171,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Notifications",
icon: BellRing,
},
{
value: "voice",
label: "Voice",
icon: Volume2,
},
{
value: "experimental",
label: "Experiments",
@@ -807,6 +816,8 @@ export function renderSettingsSection(
onSetSoundForSlot={props.onSetSoundForSlot}
/>
);
case "voice":
return <VoiceSettingsCard />;
case "experimental":
return <ExperimentalFeaturesCard />;
case "agents":
@@ -58,6 +58,7 @@ const settingsNavGroups: Array<{
"profile",
"appearance",
"notifications",
"voice",
"shortcuts",
"custom-emoji",
"local-archive",
@@ -0,0 +1,256 @@
import * as React from "react";
import { ChevronDown, Play, Volume2 } from "lucide-react";
import { invokeTauri } from "@/shared/api/tauri";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Switch } from "@/shared/ui/switch";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import {
selectedVoiceForBackend,
type VoiceRegistryEntry,
voiceOptionLabel,
voicesForBackend,
} from "./voiceSettingsLogic";
export type TtsSettings = {
version: number;
agentTextToSpeech: boolean;
voicePreferences: string[];
};
export function VoiceSettingsCard() {
const [settings, setSettings] = React.useState<TtsSettings | null>(null);
const [registry, setRegistry] = React.useState<VoiceRegistryEntry[]>([]);
const [busy, setBusy] = React.useState(false);
const [previewing, setPreviewing] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let disposed = false;
Promise.all([
invokeTauri<TtsSettings>("get_tts_settings"),
invokeTauri<VoiceRegistryEntry[]>("list_voice_registry"),
])
.then(([nextSettings, nextRegistry]) => {
if (!disposed) {
setSettings(nextSettings);
setRegistry(nextRegistry);
}
})
.catch((loadError) => {
if (!disposed) {
setError(
loadError instanceof Error
? loadError.message
: "Voice settings could not be loaded.",
);
}
});
return () => {
disposed = true;
};
}, []);
const saveEnabled = React.useCallback(async (enabled: boolean) => {
setBusy(true);
setError(null);
try {
const saved = await invokeTauri<TtsSettings>("set_tts_enabled", {
enabled,
});
setSettings(saved);
} catch (saveError) {
try {
const state = await invokeTauri<{ tts_enabled: boolean }>(
"get_huddle_state",
);
setSettings((current) =>
current
? { ...current, agentTextToSpeech: state.tts_enabled }
: current,
);
} catch {
// Keep the last confirmed state when native reconciliation is
// unavailable; the visible save error makes the failure explicit.
}
setError(
saveError instanceof Error
? saveError.message
: "Voice settings could not be saved.",
);
} finally {
setBusy(false);
}
}, []);
const savePocketVoice = React.useCallback(async (voiceKey: string) => {
setBusy(true);
setError(null);
try {
const saved = await invokeTauri<TtsSettings>("set_pocket_voice", {
voiceKey,
});
setSettings(saved);
} catch (saveError) {
setError(
saveError instanceof Error
? saveError.message
: "Voice settings could not be saved.",
);
} finally {
setBusy(false);
}
}, []);
const voices = voicesForBackend(registry, "pocket");
const selectedVoice = selectedVoiceForBackend(
settings?.voicePreferences ?? [],
voices,
);
const enabled = settings?.agentTextToSpeech ?? true;
const controlsDisabled = !settings || busy || !enabled;
return (
<section className="min-w-0" data-testid="settings-voice">
<SettingsSectionHeader
title="Voice"
description="Choose whether Buzz reads new agent responses aloud during an active huddle."
/>
<div className="flex flex-col gap-4">
<SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="agent-text-to-speech-switch"
>
Agent text to speech
</label>
<p className="text-sm text-muted-foreground">
Read new agent messages aloud in the order they arrive.
</p>
</div>
<Switch
checked={enabled}
data-testid="agent-text-to-speech-toggle"
disabled={!settings || busy}
id="agent-text-to-speech-switch"
onCheckedChange={(checked) => {
if (settings) void saveEnabled(checked);
}}
/>
</SettingsOptionRow>
</SettingsOptionGroup>
<div
aria-disabled={!enabled}
className={cn(
"transition-opacity",
!enabled && "pointer-events-none opacity-45",
)}
data-testid="pocket-voice-controls"
>
<SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">Pocket TTS voice</p>
<p className="text-sm text-muted-foreground">
Voice files stay private on this device.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Pocket TTS voice: ${selectedVoice?.displayName ?? "Mary"}`}
className="min-w-32 justify-between"
data-testid="pocket-voice-selector"
disabled={controlsDisabled}
variant="outline"
>
{selectedVoice
? voiceOptionLabel(selectedVoice, voices)
: "Mary"}
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-80 overflow-y-auto"
>
<DropdownMenuRadioGroup
onValueChange={(voiceKey) => {
if (settings) void savePocketVoice(voiceKey);
}}
value={selectedVoice?.key}
>
{voices.map((voice) => (
<DropdownMenuRadioItem
key={voice.key}
value={voice.key}
>
{voiceOptionLabel(voice, voices)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button
aria-label={`Preview ${selectedVoice?.displayName ?? "Mary"}`}
data-testid="pocket-voice-preview"
disabled={controlsDisabled || previewing || !selectedVoice}
onClick={() => {
if (!selectedVoice) return;
setPreviewing(true);
setError(null);
void invokeTauri<void>("preview_pocket_voice", {
voiceKey: selectedVoice.key,
})
.catch((previewError) => {
setError(
previewError instanceof Error
? previewError.message
: "Voice preview could not be played.",
);
})
.finally(() => setPreviewing(false));
}}
size="sm"
variant="outline"
>
{previewing ? (
<Volume2 className="h-4 w-4 animate-pulse" />
) : (
<Play className="h-4 w-4" />
)}
Preview
</Button>
</div>
</SettingsOptionRow>
</SettingsOptionGroup>
</div>
{error && (
<p
className="text-sm text-destructive"
data-testid="voice-settings-error"
role="alert"
>
{error}
</p>
)}
</div>
</section>
);
}
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
selectedVoiceForBackend,
voiceOptionLabel,
voicesForBackend,
} from "./voiceSettingsLogic.ts";
const voice = (key, displayName, fallbackKey = "pocket:mary") => ({
key,
displayName,
backend: "pocket",
backendName: "Pocket TTS",
availability: "bundled",
fallbackKey,
referenceFile: `${key}.wav`,
provenance: {
source: "bundled",
contentHash: null,
license: null,
sourceUrl: null,
},
});
test("Pocket-only V1 filters the shared registry by backend", () => {
const registry = [
voice("pocket:mary", "Mary", null),
{ ...voice("siri:aaron", "Aaron"), backend: "siri" },
];
assert.deepEqual(
voicesForBackend(registry, "pocket").map((entry) => entry.key),
["pocket:mary"],
);
});
test("local selection uses the first compatible qualified preference", () => {
const voices = [
voice("pocket:mary", "Mary", null),
voice("pocket:eve", "Eve"),
];
assert.equal(
selectedVoiceForBackend(["siri:aaron", "pocket:eve", "pocket:mary"], voices)
?.key,
"pocket:eve",
);
});
test("duplicate display labels remain distinct by content-derived key", () => {
const voices = [
voice("pocket:imported:aaa", "Jim"),
voice("pocket:imported:bbb", "Jim"),
];
assert.equal(
selectedVoiceForBackend(["pocket:imported:bbb"], voices)?.key,
"pocket:imported:bbb",
);
assert.equal(voiceOptionLabel(voices[0], voices), "Jim · aaa");
assert.equal(voiceOptionLabel(voices[1], voices), "Jim · bbb");
});
@@ -0,0 +1,57 @@
export type VoiceAvailability =
| "bundled"
| "installed"
| "downloadable"
| "unavailable";
export type VoiceRegistryEntry = {
key: string;
displayName: string;
backend: string;
backendName: string;
availability: VoiceAvailability;
fallbackKey: string | null;
referenceFile: string | null;
provenance: {
source: string;
contentHash: string | null;
license: string | null;
sourceUrl: string | null;
};
};
export function voicesForBackend(
registry: readonly VoiceRegistryEntry[],
backend: string,
): VoiceRegistryEntry[] {
return registry.filter(
(voice) =>
voice.backend === backend &&
(voice.availability === "bundled" || voice.availability === "installed"),
);
}
export function selectedVoiceForBackend(
preferences: readonly string[],
voices: readonly VoiceRegistryEntry[],
): VoiceRegistryEntry | undefined {
for (const key of preferences) {
const voice = voices.find((candidate) => candidate.key === key);
if (voice) return voice;
}
return voices.find((voice) => voice.fallbackKey === null) ?? voices[0];
}
export function voiceOptionLabel(
voice: VoiceRegistryEntry,
voices: readonly VoiceRegistryEntry[],
): string {
const duplicateLabel = voices.some(
(candidate) =>
candidate.key !== voice.key &&
candidate.displayName === voice.displayName,
);
if (!duplicateLabel) return voice.displayName;
const identitySuffix = voice.key.split(":").at(-1)?.slice(-8) ?? voice.key;
return `${voice.displayName} · ${identitySuffix}`;
}