mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add Pocket voice settings
Signed-off-by: John Tennant <johnmatthewtennant@gmail.com> Signed-off-by: John Tennant <jtennant@block.xyz> Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
committed by
John Tennant
parent
ace19c659e
commit
fc61d30ecc
@@ -0,0 +1,154 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createInitialMembershipGate,
|
||||
createLatestStateGate,
|
||||
createOrderedSpeaker,
|
||||
speakableAgentText,
|
||||
} from "./ttsLiveMessages.ts";
|
||||
|
||||
const agents = new Set(["agent"]);
|
||||
const base = {
|
||||
id: "1",
|
||||
kind: 9,
|
||||
pubkey: "agent",
|
||||
content: "Hello there",
|
||||
tags: [],
|
||||
};
|
||||
|
||||
test("speaks only new agent-authored text message events", () => {
|
||||
assert.equal(speakableAgentText(base, agents, "human"), "Hello there");
|
||||
assert.equal(
|
||||
speakableAgentText({ ...base, kind: 7 }, agents, "human"),
|
||||
null,
|
||||
"reactions and other event kinds are excluded",
|
||||
);
|
||||
assert.equal(
|
||||
speakableAgentText({ ...base, kind: 10 }, agents, "human"),
|
||||
null,
|
||||
"edits and status events are excluded",
|
||||
);
|
||||
assert.equal(
|
||||
speakableAgentText({ ...base, pubkey: "human" }, agents, "human"),
|
||||
null,
|
||||
"human-authored messages are excluded",
|
||||
);
|
||||
assert.equal(
|
||||
speakableAgentText({ ...base, content: " " }, agents, "human"),
|
||||
null,
|
||||
"empty and non-text content are excluded",
|
||||
);
|
||||
assert.equal(
|
||||
speakableAgentText(
|
||||
{ ...base, content: "[System] tool started" },
|
||||
agents,
|
||||
"human",
|
||||
),
|
||||
null,
|
||||
"legacy system rows are excluded",
|
||||
);
|
||||
});
|
||||
|
||||
test("strips attachment markup and skips attachment-only events", () => {
|
||||
const url = "https://cdn.example/voice.png";
|
||||
const tags = [["imeta", `url ${url}`, "m image/png"]];
|
||||
assert.equal(
|
||||
speakableAgentText(
|
||||
{ ...base, content: ``, tags },
|
||||
agents,
|
||||
"human",
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
speakableAgentText(
|
||||
{ ...base, content: `Here is the diagram.\n\n`, tags },
|
||||
agents,
|
||||
"human",
|
||||
),
|
||||
"Here is the diagram.",
|
||||
);
|
||||
assert.equal(
|
||||
speakableAgentText(
|
||||
{ ...base, content: `||\n\n||`, tags },
|
||||
agents,
|
||||
"human",
|
||||
),
|
||||
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) => {
|
||||
if (text === "first") await firstBlocked;
|
||||
spoken.push(text);
|
||||
}, assert.fail);
|
||||
|
||||
speaker.enqueue("first");
|
||||
speaker.enqueue("second");
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(spoken, []);
|
||||
releaseFirst();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(spoken, ["first", "second"]);
|
||||
});
|
||||
|
||||
test("disabling cancels queued speech and rejects new messages until enabled", async () => {
|
||||
const invoked = [];
|
||||
let releaseFirst;
|
||||
const firstBlocked = new Promise((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
const speaker = createOrderedSpeaker(async (text) => {
|
||||
invoked.push(text);
|
||||
if (text === "first") await firstBlocked;
|
||||
}, assert.fail);
|
||||
|
||||
speaker.enqueue("first");
|
||||
speaker.enqueue("queued-before-off");
|
||||
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"]);
|
||||
});
|
||||
|
||||
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 gate = createInitialMembershipGate((event) => delivered.push(event));
|
||||
gate.push("unverified");
|
||||
gate.fail();
|
||||
assert.deepEqual(delivered, []);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
export type LiveTtsEvent = {
|
||||
id: string;
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
};
|
||||
|
||||
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 speakableAgentText(
|
||||
event: LiveTtsEvent,
|
||||
agentPubkeys: ReadonlySet<string>,
|
||||
selfPubkey: string | null,
|
||||
): string | null {
|
||||
if (event.kind !== 9) return null;
|
||||
if (!agentPubkeys.has(event.pubkey)) return null;
|
||||
if (event.pubkey === selfPubkey) return null;
|
||||
const content = textWithoutAttachments(event).trim();
|
||||
if (content.length <= 1) return null;
|
||||
if (content.startsWith("[System]")) return null;
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => Promise<void>,
|
||||
onError: (error: unknown) => void,
|
||||
): {
|
||||
enqueue: (text: string) => void;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
} {
|
||||
let tail = Promise.resolve();
|
||||
let enabled = true;
|
||||
let generation = 0;
|
||||
return {
|
||||
enqueue(text) {
|
||||
if (!enabled) return;
|
||||
const queuedGeneration = generation;
|
||||
tail = tail
|
||||
.then(() => {
|
||||
if (!enabled || generation !== queuedGeneration) return;
|
||||
return speak(text);
|
||||
})
|
||||
.catch(onError);
|
||||
},
|
||||
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): {
|
||||
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;
|
||||
pending = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
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,
|
||||
speakableAgentText,
|
||||
} from "./ttsLiveMessages";
|
||||
|
||||
const AGENT_PUBKEY_REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
@@ -20,6 +28,7 @@ export function useTtsSubscription(
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | null = null;
|
||||
let unlistenHuddleState: (() => void) | null = null;
|
||||
|
||||
// ── Agent identity (authoritative, fail-closed) ───────────────────────
|
||||
//
|
||||
@@ -33,42 +42,98 @@ export function useTtsSubscription(
|
||||
let agentsLoaded = false;
|
||||
const agentPubkeys = new Set<string>();
|
||||
|
||||
async function loadAgentPubkeys() {
|
||||
const speakInOrder = createOrderedSpeaker(
|
||||
async (text) => {
|
||||
if (!disposed) {
|
||||
await invoke("speak_agent_message", { text });
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
console.warn("[huddle] TTS speak failed:", err);
|
||||
},
|
||||
);
|
||||
|
||||
const deliver = (event: Parameters<typeof speakableAgentText>[0]) => {
|
||||
if (!agentsLoaded || disposed) return;
|
||||
const text = speakableAgentText(
|
||||
event,
|
||||
agentPubkeys,
|
||||
selfPubkeyRef.current,
|
||||
);
|
||||
if (text) speakInOrder.enqueue(text);
|
||||
};
|
||||
const initialMembershipGate = createInitialMembershipGate(deliver);
|
||||
|
||||
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) 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) => {
|
||||
if (!disposed) applyBootstrap({ tts_enabled: false });
|
||||
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 kind:9, limit:0 subscription receives future 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).
|
||||
if (seenEventIds.has(event.id)) return;
|
||||
seenEventIds.add(event.id);
|
||||
@@ -78,20 +143,9 @@ 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.
|
||||
initialMembershipGate.push(event);
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (disposed) {
|
||||
@@ -106,7 +160,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,252 @@
|
||||
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">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
|
||||
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">
|
||||
<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,62 @@
|
||||
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:marius", "Marius"),
|
||||
];
|
||||
assert.equal(
|
||||
selectedVoiceForBackend(
|
||||
["siri:aaron", "pocket:marius", "pocket:mary"],
|
||||
voices,
|
||||
)?.key,
|
||||
"pocket:marius",
|
||||
);
|
||||
});
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildChannelAuxFilter,
|
||||
buildChannelReactionAuxFilter,
|
||||
buildChannelStructuralAuxFilter,
|
||||
buildHuddleTtsLiveFilter,
|
||||
} from "./relayChannelFilters.ts";
|
||||
|
||||
const CHANNEL = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
|
||||
@@ -14,6 +15,14 @@ const IDS = [
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
];
|
||||
|
||||
test("huddle TTS filter is future-only kind:9 with no same-second replay", () => {
|
||||
assert.deepEqual(buildHuddleTtsLiveFilter(CHANNEL), {
|
||||
kinds: [9],
|
||||
"#h": [CHANNEL],
|
||||
limit: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: reaction (kind:7) and reaction-removal (kind:5) events carry only
|
||||
// an `e` tag, no channel `h` tag. An `#h`-scoped aux query never matches them,
|
||||
// so removed historical reactions reappear. The aux filters must key on `#e`
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
KIND_DELETION,
|
||||
KIND_NIP29_DELETE_EVENT,
|
||||
KIND_REACTION,
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_EDIT,
|
||||
} from "@/shared/constants/kinds";
|
||||
import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
|
||||
@@ -40,6 +41,17 @@ export function buildChannelFilter(
|
||||
return filter;
|
||||
}
|
||||
|
||||
/** Strictly live huddle message filter: zero stored rows, future kind:9 only. */
|
||||
export function buildHuddleTtsLiveFilter(
|
||||
channelId: string,
|
||||
): RelaySubscriptionFilter {
|
||||
return {
|
||||
kinds: [KIND_STREAM_MESSAGE],
|
||||
"#h": [channelId],
|
||||
limit: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* History filter for cold-load and scrollback: message kinds *only*, so the
|
||||
* `limit` budget buys visible message depth. Auxiliary events (reactions,
|
||||
|
||||
@@ -146,6 +146,11 @@ type MockSearchProfileSeed = {
|
||||
type E2eConfig = {
|
||||
mode?: "mock" | "relay";
|
||||
mock?: {
|
||||
ttsSettings?: {
|
||||
version: number;
|
||||
agentTextToSpeech: boolean;
|
||||
voicePreferences: string[];
|
||||
};
|
||||
/** Advertised HEAD for the first mock project without adding that branch. */
|
||||
projectHeadBranch?: string;
|
||||
/** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */
|
||||
@@ -9678,6 +9683,88 @@ export function maybeInstallE2eTauriMocks() {
|
||||
window.__BUZZ_E2E_COMMAND_LOG__?.push({ command, payload });
|
||||
|
||||
switch (command) {
|
||||
case "get_tts_settings":
|
||||
return (
|
||||
activeConfig?.mock?.ttsSettings ?? {
|
||||
version: 1,
|
||||
agentTextToSpeech: true,
|
||||
voicePreferences: ["pocket:mary"],
|
||||
}
|
||||
);
|
||||
case "list_voice_registry":
|
||||
return [
|
||||
{
|
||||
key: "pocket:mary",
|
||||
displayName: "Mary",
|
||||
backend: "pocket",
|
||||
backendName: "Pocket TTS",
|
||||
availability: "bundled",
|
||||
fallbackKey: null,
|
||||
referenceFile: "reference_sample.wav",
|
||||
provenance: {
|
||||
source: "bundled",
|
||||
contentHash:
|
||||
"a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f",
|
||||
license: "CC-BY-4.0",
|
||||
sourceUrl: "https://datashare.ed.ac.uk/handle/10283/3443",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "pocket:marius",
|
||||
displayName: "Marius",
|
||||
backend: "pocket",
|
||||
backendName: "Pocket TTS",
|
||||
availability: "bundled",
|
||||
fallbackKey: "pocket:mary",
|
||||
referenceFile: "marius.wav",
|
||||
provenance: {
|
||||
source: "bundled",
|
||||
contentHash:
|
||||
"076968c3122520f3412eb7090e8c1c3f75fe57be1e24a2f96465583d84c71e16",
|
||||
license: "CC0-1.0",
|
||||
sourceUrl:
|
||||
"https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/voice-donations/Selfie.wav",
|
||||
},
|
||||
},
|
||||
];
|
||||
case "set_tts_enabled": {
|
||||
const enabled = (payload as { enabled?: boolean })?.enabled;
|
||||
if (typeof enabled !== "boolean")
|
||||
throw new Error("Missing text-to-speech enabled state");
|
||||
const settings = {
|
||||
version: 1,
|
||||
agentTextToSpeech: enabled,
|
||||
voicePreferences: activeConfig?.mock?.ttsSettings
|
||||
?.voicePreferences ?? ["pocket:mary"],
|
||||
};
|
||||
if (activeConfig?.mock) activeConfig.mock.ttsSettings = settings;
|
||||
return settings;
|
||||
}
|
||||
case "set_pocket_voice": {
|
||||
const voiceKey = (payload as { voiceKey?: string })?.voiceKey;
|
||||
if (!voiceKey) throw new Error("Missing Pocket voice key");
|
||||
const current = activeConfig?.mock?.ttsSettings ?? {
|
||||
version: 1,
|
||||
agentTextToSpeech: true,
|
||||
voicePreferences: ["pocket:mary"],
|
||||
};
|
||||
const firstPocketIndex = current.voicePreferences.findIndex((key) =>
|
||||
key.startsWith("pocket:"),
|
||||
);
|
||||
const preferences = current.voicePreferences.filter(
|
||||
(key) => !key.startsWith("pocket:"),
|
||||
);
|
||||
preferences.splice(
|
||||
firstPocketIndex < 0 ? preferences.length : firstPocketIndex,
|
||||
0,
|
||||
voiceKey,
|
||||
);
|
||||
const settings = { ...current, voicePreferences: preferences };
|
||||
if (activeConfig?.mock) activeConfig.mock.ttsSettings = settings;
|
||||
return settings;
|
||||
}
|
||||
case "preview_pocket_voice":
|
||||
return null;
|
||||
case "get_builderlab_auth":
|
||||
return activeConfig?.mock?.builderlabAuth ?? null;
|
||||
case "start_builderlab_login": {
|
||||
|
||||
Reference in New Issue
Block a user