diff --git a/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs b/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs index 2d806c0b2..3cd95eb3d 100644 --- a/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs +++ b/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs @@ -2,11 +2,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + classifySpeakableAgentText, createInitialMembershipGate, createLatestStateGate, createOrderedSpeaker, routeLiveAgentText, - speakableAgentText, } from "./ttsLiveMessages.ts"; const agents = new Set(["agent"]); @@ -18,59 +18,48 @@ const base = { 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( - speakableAgentText(base, agents, "human", CHANNEL), - "Hello there", - ); - assert.equal( - speakableAgentText({ ...base, kind: 40002 }, agents, "human", CHANNEL), + speakableText({ ...base, kind: 40002 }), "Hello there", "managed stream-message-v2 replies are spoken", ); assert.equal( - speakableAgentText({ ...base, kind: 7 }, agents, "human", CHANNEL), + speakableText({ ...base, kind: 7 }), null, "reactions and other event kinds are excluded", ); assert.equal( - speakableAgentText({ ...base, kind: 10 }, agents, "human", CHANNEL), + speakableText({ ...base, kind: 10 }), null, "edits and status events are excluded", ); assert.equal( - speakableAgentText({ ...base, pubkey: "human" }, agents, "human", CHANNEL), + speakableText({ ...base, pubkey: "human" }), null, "human-authored messages are excluded", ); assert.equal( - speakableAgentText({ ...base, content: " " }, agents, "human", CHANNEL), + speakableText({ ...base, content: " " }), null, "empty and non-text content are excluded", ); assert.equal( - speakableAgentText({ ...base, content: "K" }, agents, "human", CHANNEL), + speakableText({ ...base, content: "K" }), "K", "one-character agent text remains speakable", ); assert.equal( - speakableAgentText( - { ...base, content: "[System] tool started" }, - agents, - "human", - CHANNEL, - ), + speakableText({ ...base, content: "[System] tool started" }), null, "legacy system rows are excluded", ); assert.equal( - speakableAgentText( - { ...base, tags: [["h", "another-huddle"]] }, - agents, - "human", - CHANNEL, - ), + speakableText({ ...base, tags: [["h", "another-huddle"]] }), null, "messages for another huddle are excluded", ); @@ -134,30 +123,19 @@ 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( - speakableAgentText( - { ...base, content: `![image](${url})`, tags }, - agents, - "human", - CHANNEL, - ), + speakableText({ ...base, content: `![image](${url})`, tags }), null, ); assert.equal( - speakableAgentText( - { ...base, content: `Here is the diagram.\n\n![image](${url})`, tags }, - agents, - "human", - CHANNEL, - ), + speakableText({ + ...base, + content: `Here is the diagram.\n\n![image](${url})`, + tags, + }), "Here is the diagram.", ); assert.equal( - speakableAgentText( - { ...base, content: `||\n![image](${url})\n||`, tags }, - agents, - "human", - CHANNEL, - ), + speakableText({ ...base, content: `||\n![image](${url})\n||`, tags }), null, ); }); diff --git a/desktop/src/features/huddle/lib/ttsLiveMessages.ts b/desktop/src/features/huddle/lib/ttsLiveMessages.ts index 18c9fa8eb..b809afee2 100644 --- a/desktop/src/features/huddle/lib/ttsLiveMessages.ts +++ b/desktop/src/features/huddle/lib/ttsLiveMessages.ts @@ -94,16 +94,6 @@ export function routeLiveAgentText( return enqueue(eligibility.text, routeId); } -export function speakableAgentText( - event: LiveTtsEvent, - agentPubkeys: ReadonlySet, - selfPubkey: string | null, - channelId: string, -): string | null { - return classifySpeakableAgentText(event, agentPubkeys, selfPubkey, channelId) - .text; -} - /** * Serialize native speak calls so live messages enter the bounded Pocket queue * in thread arrival order even when the bridge resolves calls asynchronously. diff --git a/desktop/src/features/huddle/lib/useTtsSubscription.ts b/desktop/src/features/huddle/lib/useTtsSubscription.ts index b7c27afc4..c77b2cfab 100644 --- a/desktop/src/features/huddle/lib/useTtsSubscription.ts +++ b/desktop/src/features/huddle/lib/useTtsSubscription.ts @@ -193,31 +193,27 @@ export function useTtsSubscription( const seenOrder: string[] = []; const MAX_SEEN_EVENTS = 5000; relayClient - .subscribeLive( - buildHuddleTtsLiveFilter(ephemeralChannelId), - (event) => { - if (disposed) return; - // Dedup by event ID (covers reconnect replay). - if (seenEventIds.has(event.id)) return; - seenEventIds.add(event.id); - seenOrder.push(event.id); - if (seenOrder.length > MAX_SEEN_EVENTS) { - const oldest = seenOrder.shift(); - if (oldest !== undefined) seenEventIds.delete(oldest); - } + .subscribeLive(buildHuddleTtsLiveFilter(ephemeralChannelId), (event) => { + if (disposed) return; + // 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); + if (seenOrder.length > MAX_SEEN_EVENTS) { + const oldest = seenOrder.shift(); + if (oldest !== undefined) seenEventIds.delete(oldest); + } - // 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 }); - }, - { replayMissedHistory: true }, - ) + // 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) { void dispose(); diff --git a/desktop/src/features/settings/ui/VoiceSettingsCard.tsx b/desktop/src/features/settings/ui/VoiceSettingsCard.tsx index 47f4e43fb..0854b099e 100644 --- a/desktop/src/features/settings/ui/VoiceSettingsCard.tsx +++ b/desktop/src/features/settings/ui/VoiceSettingsCard.tsx @@ -163,7 +163,7 @@ export function VoiceSettingsCard() {
-

Voice

+

Pocket TTS voice

Voice files stay private on this device.

@@ -173,6 +173,7 @@ export function VoiceSettingsCard() {