From 008c50abf052cec507aa54f865cfd5818cf43559 Mon Sep 17 00:00:00 2001 From: Jordan McNamara Date: Fri, 2 Jan 2026 03:44:26 +1100 Subject: [PATCH 1/4] feat(pplx-ext): add TTS download plugin for messages and conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new plugin that allows users to download individual messages or entire conversations as audio files using Perplexity's text-to-speech API. Features: - Download individual messages as WAV audio files - Download entire conversations as a single concatenated audio file - Support for 4 TTS voices: Mike, Alex, Kate, Mary - Progress indicator for multi-message downloads - Integration with message action buttons and navbar Technical implementation: - Uses WebSocket communication with Perplexity's voice_over API - Real-time audio chunk streaming and collection - WAV encoding with proper RIFF headers (48kHz, 16-bit PCM, mono) - File System Access API support for Chrome with fallback - Proper error handling and user cancellation support Changes to download-file utility: - Add support for ArrayBuffer data (required for binary audio) - Add audio/wav MIME type handling - Add optional useFilePicker parameter for explicit control - Improve error handling to propagate cancellation to callers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../tts-download/MessageWrapper.loader.tsx | 24 +++ .../tts-download/NavbarWrapper.loader.tsx | 27 +++ .../plugins/_thread/tts-download/README.md | 124 ++++++++++++++ .../components/ThreadTtsDownloadButton.tsx | 160 ++++++++++++++++++ .../components/TtsDownloadButton.tsx | 110 ++++++++++++ .../components/VoiceSelectionDialog.tsx | 99 +++++++++++ .../hooks/useTtsDownloadRequest.ts | 112 ++++++++++++ .../_thread/tts-download/index.manifest.ts | 41 +++++ .../plugins/_thread/tts-download/settings.ts | 34 ++++ .../src/plugins/_thread/tts-download/types.ts | 7 + .../utils/audio-buffer-collector.ts | 41 +++++ .../tts-download/utils/download-wav.ts | 33 ++++ .../_thread/tts-download/utils/wav-encoder.ts | 78 +++++++++ .../extension/src/utils/misc/download-file.ts | 34 +++- 14 files changed, 915 insertions(+), 9 deletions(-) create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/MessageWrapper.loader.tsx create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/NavbarWrapper.loader.tsx create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/README.md create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/components/ThreadTtsDownloadButton.tsx create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/components/TtsDownloadButton.tsx create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/components/VoiceSelectionDialog.tsx create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/hooks/useTtsDownloadRequest.ts create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/index.manifest.ts create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/settings.ts create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/types.ts create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/utils/audio-buffer-collector.ts create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/utils/download-wav.ts create mode 100644 perplexity/extension/src/plugins/_thread/tts-download/utils/wav-encoder.ts diff --git a/perplexity/extension/src/plugins/_thread/tts-download/MessageWrapper.loader.tsx b/perplexity/extension/src/plugins/_thread/tts-download/MessageWrapper.loader.tsx new file mode 100644 index 000000000..82ca1e1f9 --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/MessageWrapper.loader.tsx @@ -0,0 +1,24 @@ +import CsUiGuard from "@/entrypoints/contexts/content-scripts/services/ui-guard/CsUiGuard"; +import { csUiMount } from "@/entrypoints/contexts/content-scripts/ui-groups/_root/CsUiRoot"; +import { ThreadMessageFooterSecondaryComponentRegister } from "@/entrypoints/contexts/content-scripts/ui-groups/routes/Thread/message-footer-secondary/Group"; + +import TtsDownloadButton from "./components/TtsDownloadButton"; + +function TtsDownloadButtonWrapper() { + return ( + + + + + + ); +} + +TtsDownloadButtonWrapper.displayName = "TtsDownloadButtonWrapper"; + +export default function () { + csUiMount({ + id: "plugin:thread:ttsDownload:messageFooter", + component: , + }); +} diff --git a/perplexity/extension/src/plugins/_thread/tts-download/NavbarWrapper.loader.tsx b/perplexity/extension/src/plugins/_thread/tts-download/NavbarWrapper.loader.tsx new file mode 100644 index 000000000..b79b64ff1 --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/NavbarWrapper.loader.tsx @@ -0,0 +1,27 @@ +import CsUiGuard from "@/entrypoints/contexts/content-scripts/services/ui-guard/CsUiGuard"; +import { csUiMount } from "@/entrypoints/contexts/content-scripts/ui-groups/_root/CsUiRoot"; +import { ThreadNavbarAttributesComponentRegister } from "@/entrypoints/contexts/content-scripts/ui-groups/routes/Thread/navbar-attributes/Group"; + +import ThreadTtsDownloadButton from "./components/ThreadTtsDownloadButton"; + +function ThreadTtsDownloadButtonWrapper() { + return ( + + + + + + ); +} + +ThreadTtsDownloadButtonWrapper.displayName = "ThreadTtsDownloadButtonWrapper"; + +export default function () { + csUiMount({ + id: "plugin:thread:ttsDownload:navbar", + component: , + }); +} diff --git a/perplexity/extension/src/plugins/_thread/tts-download/README.md b/perplexity/extension/src/plugins/_thread/tts-download/README.md new file mode 100644 index 000000000..9886e0d4d --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/README.md @@ -0,0 +1,124 @@ +# TTS Download Plugin + +A Complexity plugin that enables downloading Perplexity AI messages and conversations as audio files using text-to-speech. + +## Features + +- **Individual Message Download**: Download any assistant message as a WAV audio file +- **Full Conversation Download**: Download all messages in a thread as a single concatenated audio file +- **Multiple Voice Options**: Choose from 4 TTS voices (Mike, Alex, Kate, Mary) +- **Progress Tracking**: Visual progress indicator when downloading multi-message conversations +- **Seamless Integration**: Download buttons in message action bars and navbar + +## How It Works + +### Architecture + +The plugin uses Perplexity's native `voice_over` WebSocket API to generate TTS audio: + +1. **WebSocket Connection**: Establishes connection via `InternalWebSocketManager` +2. **Audio Streaming**: Receives audio as Int16Array chunks via `audio` events +3. **Chunk Collection**: `AudioBufferCollector` accumulates chunks in memory +4. **WAV Encoding**: `WavEncoder` converts PCM chunks to WAV format with proper RIFF headers +5. **File Download**: Uses File System Access API (Chrome) or blob download (fallback) + +### Key Components + +#### Components +- **`TtsDownloadButton.tsx`**: Individual message download button (in message action bar) +- **`ThreadTtsDownloadButton.tsx`**: Full conversation download button (in navbar) +- **`VoiceSelectionDialog.tsx`**: Voice picker dialog for conversation downloads + +#### Hooks +- **`useTtsDownloadRequest.ts`**: Core hook managing WebSocket communication and audio streaming + +#### Utilities +- **`AudioBufferCollector`**: Collects and manages audio chunks +- **`WavEncoder`**: Encodes PCM data to WAV format +- **`download-wav.ts`**: Handles WAV file downloads + +### Audio Format + +- **Sample Rate**: 48kHz +- **Bit Depth**: 16-bit +- **Channels**: Mono (1 channel) +- **Format**: WAV (RIFF header) + +## Usage + +### Individual Message Download + +1. Navigate to any Perplexity thread +2. Hover over an assistant message +3. Click the download icon in the action bar +4. Audio file downloads automatically with default voice + +### Full Conversation Download + +1. Click the download button in the navbar +2. Select desired voice from the dialog +3. Wait for progress to complete +4. Audio file saves with timestamp in filename + +## Settings + +- **Default Voice**: Select preferred voice for single-message downloads (Mike, Alex, Kate, Mary) +- **Enabled**: Toggle plugin on/off + +## Technical Details + +### Dependencies + +- `domObservers:thread:messageBlocks` - Required for accessing message content + +### WebSocket Protocol + +```typescript +// Request +socket.emitWithAck("voice_over", { + is_page: false, + version: "2.13", + completed: true, + uuid: backendUuid, + preset: voice, +}); + +// Response +socket.on("audio", (packet: { data: ArrayLike; uuid: string }) => { + // Handle audio chunk +}); +``` + +### File Naming + +- **Single message**: `message-{uuid}-{voice}.wav` +- **Conversation**: `conversation-{timestamp}-{voice}.wav` + +### Error Handling + +- WebSocket connection failures +- Missing audio data +- User cancellation (File System Access API) +- Individual message failures in batch downloads (continues with next message) + +## Changes to Shared Utilities + +### `download-file.ts` + +Enhanced to support binary audio data: + +- Added `ArrayBuffer` support for `data` parameter +- Added `audio/wav` and `audio/wave` MIME types +- Added `useFilePicker` option for explicit File System Access API control +- Improved error handling to propagate user cancellation + +## Future Enhancements + +Potential improvements: + +- Support for additional audio formats (MP3, OGG) +- Voice selection for individual messages +- Audio quality settings (sample rate, bit depth) +- Batch download with ZIP archive +- Pause/resume for long conversations +- Audio preview before download diff --git a/perplexity/extension/src/plugins/_thread/tts-download/components/ThreadTtsDownloadButton.tsx b/perplexity/extension/src/plugins/_thread/tts-download/components/ThreadTtsDownloadButton.tsx new file mode 100644 index 000000000..65b614a1a --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/components/ThreadTtsDownloadButton.tsx @@ -0,0 +1,160 @@ +import Tooltip from "@/components/Tooltip"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/use-toast"; +import { useThreadDomObserverStore } from "@/entrypoints/contexts/content-scripts/core-plugins/dom-observers/thread/store"; +import { useThreadMessageBlocksDomObserverStore } from "@/entrypoints/contexts/content-scripts/core-plugins/dom-observers/thread/message-blocks/store"; +import useTtsDownloadRequest from "@/plugins/_thread/tts-download/hooks/useTtsDownloadRequest"; +import type { TtsVoice } from "@/plugins/_thread/tts-download/types"; +import { downloadWavFile } from "@/plugins/_thread/tts-download/utils/download-wav"; + +import TablerDownload from "~icons/tabler/download"; +import TablerLoaderCircle from "~icons/tabler/loader-2"; + +import VoiceSelectionDialog from "./VoiceSelectionDialog"; + +export default function ThreadTtsDownloadButton() { + const [dialogOpen, setDialogOpen] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [progress, setProgress] = useState({ current: 0, total: 0 }); + const { toast } = useToast(); + + const isThreadInFlight = useThreadDomObserverStore( + (store) => store.states.isInFlight, + ); + + const messageBlocks = useThreadMessageBlocksDomObserverStore( + (store) => store.messageBlocks, + ); + + const { downloadTts } = useTtsDownloadRequest({}); + + const handleDownload = async (voice: TtsVoice) => { + // Get all assistant messages (not in-flight, has answer content) + const assistantMessages = + messageBlocks?.filter( + (block) => + !block.states.isInFlight && + block.content.answer && + block.content.backendUuid, + ) ?? []; + + if (assistantMessages.length === 0) { + toast({ + title: "No Messages", + description: "No assistant messages to download", + variant: "caution", + }); + setDialogOpen(false); + return; + } + + if (assistantMessages.length > 20) { + toast({ + title: "Warning", + description: `Downloading ${assistantMessages.length} messages may take a while`, + }); + } + + setIsDownloading(true); + setProgress({ current: 0, total: assistantMessages.length }); + + const allChunks: Int16Array[] = []; + + try { + // Download each message sequentially + for (let i = 0; i < assistantMessages.length; i++) { + const message = assistantMessages[i]; + if (!message) continue; + + setProgress({ current: i + 1, total: assistantMessages.length }); + + try { + const chunks = await downloadTts({ + voice, + backendUuid: message.content.backendUuid, + }); + + if (chunks.length > 0) { + allChunks.push(...chunks); + } + } catch (error) { + console.error( + `[TtsDownload] Failed to download message ${i + 1}:`, + error, + ); + // Continue with next message + } + } + + if (allChunks.length === 0) { + toast({ + title: "Download Failed", + description: "No audio data received", + variant: "caution", + }); + return; + } + + // Download concatenated audio + const timestamp = new Date() + .toISOString() + .slice(0, 19) + .replace(/:/g, "-"); + const filename = `conversation-${timestamp}-${voice}`; + + await downloadWavFile({ + chunks: allChunks, + filename, + }); + + toast({ + title: "Download Complete", + description: `Downloaded ${assistantMessages.length} messages as audio`, + }); + + setDialogOpen(false); + } catch (error) { + console.error("[TtsDownload] Conversation download failed:", error); + // Don't show error toast if user just cancelled the save dialog + if (error instanceof Error && error.message === "Download cancelled") { + return; + } + toast({ + title: "Download Failed", + description: "Failed to download conversation", + variant: "caution", + }); + } finally { + setIsDownloading(false); + setProgress({ current: 0, total: 0 }); + } + }; + + return ( + <> + + + + + + + ); +} diff --git a/perplexity/extension/src/plugins/_thread/tts-download/components/TtsDownloadButton.tsx b/perplexity/extension/src/plugins/_thread/tts-download/components/TtsDownloadButton.tsx new file mode 100644 index 000000000..8bf11f722 --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/components/TtsDownloadButton.tsx @@ -0,0 +1,110 @@ +import Tooltip from "@/components/Tooltip"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/use-toast"; +import { useThreadMessageBlocksDomObserverStore } from "@/entrypoints/contexts/content-scripts/core-plugins/dom-observers/thread/message-blocks/store"; +import { useThreadMessageIndexContext } from "@/entrypoints/contexts/content-scripts/ui-groups/routes/Thread/message-index-context"; +import useTtsDownloadRequest from "@/plugins/_thread/tts-download/hooks/useTtsDownloadRequest"; +import { useSettings } from "@/plugins/_thread/tts-download/settings"; +import { downloadWavFile } from "@/plugins/_thread/tts-download/utils/download-wav"; + +import TablerDownload from "~icons/tabler/download"; +import TablerLoaderCircle from "~icons/tabler/loader-2"; + +export default function TtsDownloadButton() { + const messageBlockIndex = useThreadMessageIndexContext(); + const { settings, update } = useSettings(); + const { toast } = useToast(); + + const messageBlock = useThreadMessageBlocksDomObserverStore( + (store) => store.messageBlocks?.[messageBlockIndex], + ); + + const backendUuid = messageBlock?.content.backendUuid; + const isInFlight = messageBlock?.states.isInFlight; + + const { downloadTts, isPending } = useTtsDownloadRequest({ + onComplete: async (chunks) => { + if (chunks.length === 0) { + toast({ + title: "Download Failed", + description: "No audio data received", + variant: "caution", + }); + return; + } + + try { + const filename = `message-${backendUuid?.slice(0, 8)}-${settings.defaultVoice}`; + await downloadWavFile({ + chunks, + filename, + }); + + toast({ + title: "Download Complete", + description: "Audio file saved successfully", + }); + } catch (error) { + console.error("[TtsDownload] Download failed:", error); + // Don't show error toast if user just cancelled the save dialog + if (error instanceof Error && error.message === "Download cancelled") { + return; + } + toast({ + title: "Download Failed", + description: "Failed to save audio file", + variant: "caution", + }); + } + }, + onError: () => { + toast({ + title: "Download Failed", + description: "Failed to generate audio", + variant: "caution", + }); + }, + }); + + const handleDownload = async () => { + if (!backendUuid) return; + + try { + await downloadTts({ + voice: settings.defaultVoice, + backendUuid, + }); + + // Update settings to remember last voice + await update({ + updateFn(prev: typeof settings) { + prev.defaultVoice = settings.defaultVoice; + }, + }); + } catch (error) { + console.error("[TtsDownload] Download error:", error); + } + }; + + if (!backendUuid || isInFlight) { + return null; + } + + return ( + + + + ); +} diff --git a/perplexity/extension/src/plugins/_thread/tts-download/components/VoiceSelectionDialog.tsx b/perplexity/extension/src/plugins/_thread/tts-download/components/VoiceSelectionDialog.tsx new file mode 100644 index 000000000..d29d59867 --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/components/VoiceSelectionDialog.tsx @@ -0,0 +1,99 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + RadioRoot, + RadioItem, + RadioItemControl, + RadioItemHiddenInput, + RadioItemText, +} from "@/components/ui/radio"; +import { + TTS_VOICES, + type TtsVoice, +} from "@/plugins/_thread/tts-download/types"; + +import TablerDownload from "~icons/tabler/download"; +import TablerLoaderCircle from "~icons/tabler/loader-2"; + +type VoiceSelectionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onDownload: (voice: TtsVoice) => void; + isDownloading?: boolean; + progress?: { + current: number; + total: number; + }; +}; + +export default function VoiceSelectionDialog({ + open, + onOpenChange, + onDownload, + isDownloading = false, + progress, +}: VoiceSelectionDialogProps) { + const [selectedVoice, setSelectedVoice] = useState("Mike"); + + const handleDownload = () => { + onDownload(selectedVoice); + }; + + return ( + onOpenChange(open)}> + + + Download Conversation as Audio + +
+ {isDownloading && progress ? ( +
+ +

+ Downloading message {progress.current} of {progress.total}... +

+
+ ) : ( + <> +
+

Select Voice:

+ + setSelectedVoice(value as TtsVoice) + } + > + {TTS_VOICES.map((voice) => ( + + + {voice} + + + ))} + +
+ +
+ + +
+ + )} +
+
+
+ ); +} diff --git a/perplexity/extension/src/plugins/_thread/tts-download/hooks/useTtsDownloadRequest.ts b/perplexity/extension/src/plugins/_thread/tts-download/hooks/useTtsDownloadRequest.ts new file mode 100644 index 000000000..6e842b23b --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/hooks/useTtsDownloadRequest.ts @@ -0,0 +1,112 @@ +import { useMutation } from "@tanstack/react-query"; +import type { Socket } from "socket.io-client"; + +import { APP_CONFIG } from "@/app.config"; +import { InternalWebSocketManager } from "@/entrypoints/contexts/content-scripts/core-plugins/pplx-web-socket"; +import type { TtsVoice } from "@/plugins/_thread/tts-download/types"; +import { AudioBufferCollector } from "@/plugins/_thread/tts-download/utils/audio-buffer-collector"; + +type UseTtsDownloadRequestProps = { + onComplete?: (chunks: Int16Array[]) => void; + onError?: () => void; +}; + +export default function useTtsDownloadRequest({ + onComplete, + onError, +}: UseTtsDownloadRequestProps = {}) { + const socketRef = useRef(null); + const collectorRef = useRef(null); + + const { reset, mutateAsync, isPending } = useMutation({ + mutationFn: async (params?: { voice: TtsVoice; backendUuid: string }) => { + invariant(params?.backendUuid, "[TtsDownload] Invalid context"); + + // Create a new collector for this download + collectorRef.current = new AudioBufferCollector(); + + const [socket] = await tryCatch(() => + InternalWebSocketManager.getInstance().handShake({ + upgrade: APP_CONFIG.BROWSER === "chrome", + }), + ); + + socketRef.current = socket; + + invariant(socket != null, "[TtsDownload] Invalid context"); + + const handleAudio = (packet: { + data: ArrayLike | null; + uuid: string; + }) => { + if ( + packet.uuid === params.backendUuid && + packet.data != null && + collectorRef.current + ) { + const chunk = new Int16Array(packet.data); + collectorRef.current.addChunk(chunk); + } + }; + + const handleError = (packet: unknown) => { + if ( + packet != null && + typeof packet === "object" && + "data" in packet && + Array.isArray(packet.data) && + packet.data.length > 1 && + "status" in packet.data[1] && + packet.data[1].status === "failed" + ) { + onError?.(); + abort(); + } + }; + + socket.io.on("packet", handleError); + socket.on("audio", handleAudio); + + await socket.emitWithAck("voice_over", { + is_page: false, + version: "2.13", + completed: true, + uuid: params.backendUuid, + preset: params.voice, + }); + + // Get collected chunks + const chunks = collectorRef.current.getAllChunks(); + + // Call completion callback + onComplete?.(chunks); + + socket.off("audio", handleAudio); + socket.io.off("packet", handleError); + + socket.disconnect(); + + return chunks; + }, + }); + + const abort = useCallback(() => { + socketRef.current?.disconnect(); + socketRef.current = null; + collectorRef.current?.clear(); + collectorRef.current = null; + reset(); + }, [reset]); + + useEffect(() => { + return () => { + abort(); + }; + }, [abort]); + + return { + abort, + isPending, + downloadTts: mutateAsync, + }; +} diff --git a/perplexity/extension/src/plugins/_thread/tts-download/index.manifest.ts b/perplexity/extension/src/plugins/_thread/tts-download/index.manifest.ts new file mode 100644 index 000000000..9d464dfaa --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/index.manifest.ts @@ -0,0 +1,41 @@ +import { + definePluginDashboardMeta, + definePluginDependencies, + definePluginMeta, +} from "@/entrypoints/services/plugins/defines"; +import type { PluginManifestExports } from "@/entrypoints/services/plugins/types"; + +import { settingsSchemas, settingsStorage } from "./settings"; + +declare module "@/entrypoints/services/plugins/types" { + interface PluginsRegistry { + [meta.id]: typeof manifest; + } +} + +const meta = definePluginMeta({ + id: "thread:ttsDownload", + name: "TTS Download", + description: + "Download individual messages or entire conversations as audio files using text-to-speech.", +}); + +const dashboardMeta = definePluginDashboardMeta({ + tags: ["ui"], + categories: ["thread"], + uiRouteSegment: "tts-download", +}); + +const dependencies = definePluginDependencies({ + plugins: ["domObservers:thread:messageBlocks"], +}); + +const manifest = { + meta, + dependencies, + dashboardMeta, + settingsSchemas, + settingsStorage, +} satisfies PluginManifestExports; + +export default manifest; diff --git a/perplexity/extension/src/plugins/_thread/tts-download/settings.ts b/perplexity/extension/src/plugins/_thread/tts-download/settings.ts new file mode 100644 index 000000000..320e8a987 --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/settings.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +import { definePluginSettingsSchemas } from "@/entrypoints/services/plugins/defines"; +import { PluginSettingsService } from "@/entrypoints/services/plugins/settings"; +import usePluginSettings from "@/entrypoints/services/plugins/settings/usePluginSettings"; + +import { TtsVoiceSchema } from "./types"; + +export const settingsSchemas = definePluginSettingsSchemas({ + 1: { + schema: z.object({ + enabled: z.boolean(), + defaultVoice: TtsVoiceSchema, + }), + fallback: { + enabled: false, + defaultVoice: "Mike" as const, + }, + }, +}); + +type LatestSettingsSchema = + (typeof settingsSchemas)[keyof typeof settingsSchemas]; + +export type Settings = z.infer; + +export const settingsStorage = new PluginSettingsService({ + id: "thread:ttsDownload", + settingsSchemas, +}); + +export function useSettings() { + return usePluginSettings(settingsStorage); +} diff --git a/perplexity/extension/src/plugins/_thread/tts-download/types.ts b/perplexity/extension/src/plugins/_thread/tts-download/types.ts new file mode 100644 index 000000000..b1c0acc95 --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const TTS_VOICES = ["Mike", "Alex", "Kate", "Mary"] as const; + +export const TtsVoiceSchema = z.enum(TTS_VOICES); + +export type TtsVoice = (typeof TTS_VOICES)[number]; diff --git a/perplexity/extension/src/plugins/_thread/tts-download/utils/audio-buffer-collector.ts b/perplexity/extension/src/plugins/_thread/tts-download/utils/audio-buffer-collector.ts new file mode 100644 index 000000000..1c2baf77c --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/utils/audio-buffer-collector.ts @@ -0,0 +1,41 @@ +/** + * Collects audio chunks for download + */ +export class AudioBufferCollector { + private chunks: Int16Array[] = []; + + /** + * Add an audio chunk to the collection + */ + addChunk(chunk: Int16Array): void { + this.chunks.push(chunk); + } + + /** + * Get all collected chunks + */ + getAllChunks(): Int16Array[] { + return this.chunks; + } + + /** + * Clear all collected chunks + */ + clear(): void { + this.chunks = []; + } + + /** + * Get total number of samples across all chunks + */ + getTotalSamples(): number { + return this.chunks.reduce((total, chunk) => total + chunk.length, 0); + } + + /** + * Check if any chunks have been collected + */ + hasChunks(): boolean { + return this.chunks.length > 0; + } +} diff --git a/perplexity/extension/src/plugins/_thread/tts-download/utils/download-wav.ts b/perplexity/extension/src/plugins/_thread/tts-download/utils/download-wav.ts new file mode 100644 index 000000000..bf161a7aa --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/utils/download-wav.ts @@ -0,0 +1,33 @@ +import downloadFile from "@/utils/misc/download-file"; + +import { WavEncoder } from "./wav-encoder"; + +type DownloadWavFileProps = { + chunks: Int16Array[]; + filename: string; + sampleRate?: number; +}; + +/** + * Download audio chunks as a WAV file + */ +export async function downloadWavFile({ + chunks, + filename, + sampleRate = 48000, +}: DownloadWavFileProps): Promise { + // Encode chunks to WAV format + const wavBuffer = WavEncoder.encode(chunks, sampleRate); + + // Ensure filename has .wav extension + const filenameWithExt = filename.endsWith(".wav") + ? filename + : `${filename}.wav`; + + // Download using the existing download utility + await downloadFile({ + data: wavBuffer, + filename: filenameWithExt, + mimeType: "audio/wav", + }); +} diff --git a/perplexity/extension/src/plugins/_thread/tts-download/utils/wav-encoder.ts b/perplexity/extension/src/plugins/_thread/tts-download/utils/wav-encoder.ts new file mode 100644 index 000000000..0f15df89a --- /dev/null +++ b/perplexity/extension/src/plugins/_thread/tts-download/utils/wav-encoder.ts @@ -0,0 +1,78 @@ +/** + * Encodes Int16Array PCM audio chunks to WAV format + */ +export class WavEncoder { + /** + * Encode PCM audio chunks to WAV format + * @param chunks - Array of Int16Array chunks containing PCM audio data + * @param sampleRate - Sample rate in Hz (default: 48000) + * @param numChannels - Number of audio channels (default: 1 for mono) + * @returns ArrayBuffer containing the complete WAV file + */ + static encode( + chunks: Int16Array[], + sampleRate = 48000, + numChannels = 1, + ): ArrayBuffer { + // Calculate total samples + const totalSamples = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + + // Calculate sizes + const bitsPerSample = 16; + const bytesPerSample = bitsPerSample / 8; + const blockAlign = numChannels * bytesPerSample; + const byteRate = sampleRate * blockAlign; + const dataSize = totalSamples * bytesPerSample; + const fileSize = 44 + dataSize; // 44 bytes for WAV header + + // Create buffer for entire WAV file + const buffer = new ArrayBuffer(fileSize); + const view = new DataView(buffer); + + // Write WAV header + this.writeString(view, 0, "RIFF"); // ChunkID + view.setUint32(4, fileSize - 8, true); // ChunkSize + this.writeString(view, 8, "WAVE"); // Format + + // Write fmt sub-chunk + this.writeString(view, 12, "fmt "); // Subchunk1ID + view.setUint32(16, 16, true); // Subchunk1Size (16 for PCM) + view.setUint16(20, 1, true); // AudioFormat (1 = PCM) + view.setUint16(22, numChannels, true); // NumChannels + view.setUint32(24, sampleRate, true); // SampleRate + view.setUint32(28, byteRate, true); // ByteRate + view.setUint16(32, blockAlign, true); // BlockAlign + view.setUint16(34, bitsPerSample, true); // BitsPerSample + + // Write data sub-chunk + this.writeString(view, 36, "data"); // Subchunk2ID + view.setUint32(40, dataSize, true); // Subchunk2Size + + // Write audio data + let offset = 44; + for (const chunk of chunks) { + for (let i = 0; i < chunk.length; i++) { + const sample = chunk[i]; + if (sample !== undefined) { + view.setInt16(offset, sample, true); + } + offset += 2; + } + } + + return buffer; + } + + /** + * Write a string to a DataView at the specified offset + */ + private static writeString( + view: DataView, + offset: number, + str: string, + ): void { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); + } + } +} diff --git a/perplexity/extension/src/utils/misc/download-file.ts b/perplexity/extension/src/utils/misc/download-file.ts index 6dcca54fd..993f9eccb 100644 --- a/perplexity/extension/src/utils/misc/download-file.ts +++ b/perplexity/extension/src/utils/misc/download-file.ts @@ -4,6 +4,8 @@ const MIME_TYPE_TO_EXTENSION: Record = { "application/json": ".json", "text/markdown": ".md", "text/plain": ".txt", + "audio/wav": ".wav", + "audio/wave": ".wav", }; const EXTENSION_TO_MIME_TYPE: Record = Object.entries( @@ -20,15 +22,23 @@ export default async function downloadFile({ data, filename, mimeType, + useFilePicker = false, }: { - data: string; + data: string | ArrayBuffer; filename: string; mimeType?: string; + useFilePicker?: boolean; }) { const resolvedMimeType = mimeType || inferMimeTypeFromFilename(filename) || "application/json"; - if (APP_CONFIG.BROWSER === "chrome" && "showSaveFilePicker" in window) { + // File System Access API requires active user gesture, which is often lost + // after async operations. Only use it when explicitly requested. + if ( + useFilePicker && + APP_CONFIG.BROWSER === "chrome" && + "showSaveFilePicker" in window + ) { await downloadFileChrome(data, filename, resolvedMimeType); } else { downloadFileGeneric(data, filename, resolvedMimeType); @@ -41,7 +51,7 @@ function inferMimeTypeFromFilename(filename: string): string | null { } async function downloadFileChrome( - data: string, + data: string | ArrayBuffer, filename: string, mimeType: string, ) { @@ -64,13 +74,20 @@ async function downloadFileChrome( await writable.write(data); await writable.close(); } catch (error: unknown) { - if (error instanceof Error && error.name !== "AbortError") { - console.error("Failed to save file:", error); + if (error instanceof Error && error.name === "AbortError") { + // User cancelled the save dialog - throw so caller knows + throw new Error("Download cancelled"); } + console.error("Failed to save file:", error); + throw error; } } -function downloadFileGeneric(data: string, filename: string, mimeType: string) { +function downloadFileGeneric( + data: string | ArrayBuffer, + filename: string, + mimeType: string, +) { try { const blob = new Blob([data], { type: mimeType }); const url = URL.createObjectURL(blob); @@ -82,8 +99,7 @@ function downloadFileGeneric(data: string, filename: string, mimeType: string) { document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error: unknown) { - if (error instanceof Error) { - console.error("Failed to save file:", error); - } + console.error("Failed to save file:", error); + throw error; } } From 7e8fde857a4e1830fa5c5f701082eed7afcf53e9 Mon Sep 17 00:00:00 2001 From: Jordan McNamara Date: Wed, 21 Jan 2026 22:15:26 +1100 Subject: [PATCH 2/4] Update perplexity/extension/src/plugins/_thread/tts-download/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- perplexity/extension/src/plugins/_thread/tts-download/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perplexity/extension/src/plugins/_thread/tts-download/README.md b/perplexity/extension/src/plugins/_thread/tts-download/README.md index 9886e0d4d..17834d336 100644 --- a/perplexity/extension/src/plugins/_thread/tts-download/README.md +++ b/perplexity/extension/src/plugins/_thread/tts-download/README.md @@ -91,7 +91,7 @@ socket.on("audio", (packet: { data: ArrayLike; uuid: string }) => { ### File Naming -- **Single message**: `message-{uuid}-{voice}.wav` +- **Single message**: `message-{uuid.slice(0, 8)}-{voice}.wav` - **Conversation**: `conversation-{timestamp}-{voice}.wav` ### Error Handling From 6430fa630e4731b8df45955917dc0d85ce104802 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Jan 2026 00:32:04 +0000 Subject: [PATCH 3/4] Fix TTS dialog close on empty audio Co-authored-by: jordan.mcnamara7 --- .../_thread/tts-download/components/ThreadTtsDownloadButton.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/perplexity/extension/src/plugins/_thread/tts-download/components/ThreadTtsDownloadButton.tsx b/perplexity/extension/src/plugins/_thread/tts-download/components/ThreadTtsDownloadButton.tsx index 65b614a1a..f08c42376 100644 --- a/perplexity/extension/src/plugins/_thread/tts-download/components/ThreadTtsDownloadButton.tsx +++ b/perplexity/extension/src/plugins/_thread/tts-download/components/ThreadTtsDownloadButton.tsx @@ -92,6 +92,7 @@ export default function ThreadTtsDownloadButton() { description: "No audio data received", variant: "caution", }); + setDialogOpen(false); return; } From 8c90d5820aa449fbcfd04e73d75ef1eb2af2189a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Jan 2026 00:35:26 +0000 Subject: [PATCH 4/4] Fix TTS websocket cleanup on failure Co-authored-by: jordan.mcnamara7 --- .../hooks/useTtsDownloadRequest.ts | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/perplexity/extension/src/plugins/_thread/tts-download/hooks/useTtsDownloadRequest.ts b/perplexity/extension/src/plugins/_thread/tts-download/hooks/useTtsDownloadRequest.ts index 6e842b23b..26bd685a0 100644 --- a/perplexity/extension/src/plugins/_thread/tts-download/hooks/useTtsDownloadRequest.ts +++ b/perplexity/extension/src/plugins/_thread/tts-download/hooks/useTtsDownloadRequest.ts @@ -49,6 +49,8 @@ export default function useTtsDownloadRequest({ } }; + let didFail = false; + const handleError = (packet: unknown) => { if ( packet != null && @@ -59,6 +61,7 @@ export default function useTtsDownloadRequest({ "status" in packet.data[1] && packet.data[1].status === "failed" ) { + didFail = true; onError?.(); abort(); } @@ -67,26 +70,39 @@ export default function useTtsDownloadRequest({ socket.io.on("packet", handleError); socket.on("audio", handleAudio); - await socket.emitWithAck("voice_over", { - is_page: false, - version: "2.13", - completed: true, - uuid: params.backendUuid, - preset: params.voice, - }); - - // Get collected chunks - const chunks = collectorRef.current.getAllChunks(); + const cleanupSocket = () => { + socket.off("audio", handleAudio); + socket.io.off("packet", handleError); + socket.disconnect(); + if (socketRef.current === socket) { + socketRef.current = null; + } + }; - // Call completion callback - onComplete?.(chunks); + try { + await socket.emitWithAck("voice_over", { + is_page: false, + version: "2.13", + completed: true, + uuid: params.backendUuid, + preset: params.voice, + }); - socket.off("audio", handleAudio); - socket.io.off("packet", handleError); + // Get collected chunks + const chunks = collectorRef.current.getAllChunks(); - socket.disconnect(); + // Call completion callback + onComplete?.(chunks); - return chunks; + return chunks; + } catch (error) { + if (!didFail) { + onError?.(); + } + throw error; + } finally { + cleanupSocket(); + } }, });