mirror of
https://github.com/block/buzz.git
synced 2026-11-06 01:11:33 +00:00
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:
@@ -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",
|
||||
|
||||
256
desktop/src/features/settings/ui/VoiceSettingsCard.tsx
Normal file
256
desktop/src/features/settings/ui/VoiceSettingsCard.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
60
desktop/src/features/settings/ui/voiceSettingsLogic.test.mjs
Normal file
60
desktop/src/features/settings/ui/voiceSettingsLogic.test.mjs
Normal file
@@ -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");
|
||||
});
|
||||
57
desktop/src/features/settings/ui/voiceSettingsLogic.ts
Normal file
57
desktop/src/features/settings/ui/voiceSettingsLogic.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user