uncloseai.com/public/src/tts.js

750 lines
24 KiB
JavaScript

// This is free software for the public good of a permacomputer hosted at
// permacomputer.com, an always-on computer by the people, for the people.
// One which is durable, easy to repair, & distributed like tap water
// for machine learning intelligence.
//
// The permacomputer is community-owned infrastructure optimized around
// four values:
//
// TRUTH First principles, math & science, open source code freely distributed
// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control
// HARMONY Minimal waste, self-renewing systems with diverse thriving connections
// LOVE Be yourself without hurting others, cooperation through natural law
//
// This software contributes to that vision by making machine learning
// accessible to everyone through a free, open, embeddable chat interface.
// Code is seeds to sprout on any abandoned technology.
import { API_KEY, MODEL, setLastTTS, TTS_API_URL } from "./config.js";
import { getSelectedModel, getSelectedModelEndpoint, getSelectedModelMaxTokens, getSelectedModelMaxCompletionTokens } from "./models.js";
// Helper to get vault value or localStorage fallback
function getVaultOrStorage(key, defaultValue = null) {
if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) {
return window.UncloseVault.get(key, defaultValue);
}
return defaultValue;
}
// TTS playback mode: "buffered" (default) or "streaming"
export function getTTSMode() {
try {
return getVaultOrStorage("uncloseai_tts_mode", "streaming");
} catch (e) {
return "buffered";
}
}
export function setTTSMode(mode) {
try {
if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) {
window.UncloseVault.set("uncloseai_tts_mode", mode);
} else {
console.warn("Vault locked - TTS mode not persisted");
}
} catch (e) {
console.warn("Failed to save TTS mode:", e);
}
}
// Detect best audio format for this browser
// Chrome/Edge: mp3 works, MediaSource supports audio/mpeg
// Firefox: mp3 often broken on Linux; use webm+opus (works in both MSE and <audio>)
function detectAudioFormat() {
const isFirefox = navigator.userAgent.includes('Firefox');
if (isFirefox) {
// Firefox supports audio/webm;codecs=opus in both MediaSource and <audio>
console.log("Firefox detected, using webm+opus format for TTS");
return { format: 'webm', mime: 'audio/webm', mseMime: 'audio/webm;codecs=opus' };
}
// Chromium-based browsers: mp3 works, MSE supports audio/mpeg
return { format: 'mp3', mime: 'audio/mpeg', mseMime: 'audio/mpeg' };
}
const AUDIO_FORMAT = detectAudioFormat();
// In-memory TTS cache - keyed by text+voice+model, cleared when tab closes
const ttsCache = new Map();
// Split long text into chunks at sentence boundaries.
// F5-TTS has no input cap, but smaller chunks shorten time-to-first-audio:
// chunk 1 is intentionally small (~500 chars ~ 5s wall) so playback starts ASAP,
// later chunks fetch in pipeline while chunk 1 plays.
const TTS_CHUNK_MAX = 2500; // upper bound per chunk (server-side comfortable)
const TTS_FIRST_CHUNK_MAX = 500; // smaller first chunk for low time-to-first-audio
function splitOne(remaining, limit) {
if (remaining.length <= limit) return [remaining, ''];
const slice = remaining.substring(0, limit);
let splitAt = -1;
for (let i = slice.length - 1; i > limit * 0.3; i--) {
const ch = slice[i];
if ((ch === '.' || ch === '!' || ch === '?' || ch === '\n') &&
(i === slice.length - 1 || slice[i + 1] === ' ' || slice[i + 1] === '\n')) {
splitAt = i + 1;
break;
}
}
if (splitAt === -1) splitAt = slice.lastIndexOf(' ');
if (splitAt <= 0) splitAt = limit;
return [remaining.substring(0, splitAt).trim(), remaining.substring(splitAt).trim()];
}
function splitTextIntoChunks(text) {
if (text.length <= TTS_FIRST_CHUNK_MAX) return [text];
const chunks = [];
let remaining = text;
let isFirst = true;
while (remaining.length > 0) {
const limit = isFirst ? TTS_FIRST_CHUNK_MAX : TTS_CHUNK_MAX;
const [head, tail] = splitOne(remaining, limit);
chunks.push(head);
remaining = tail;
isFirst = false;
}
console.log(`TTS: split ${text.length} chars into ${chunks.length} chunks:`, chunks.map((c, i) => `chunk${i + 1}=${c.length}chars`));
return chunks;
}
// Pipelined playlist player: fetches all chunks in parallel, plays each as soon
// as ready, chains via 'ended'. Exposes Audio-compatible API so downstream UI
// (pause/play, download, progress) works unchanged. Bonus: fixes Safari MP3-concat
// bug by playing each chunk as its own <audio> element.
class PlaylistAudio {
constructor(chunks, voice, model, rate) {
this._chunks = chunks;
this._rate = rate;
this._audios = new Array(chunks.length);
this._blobs = new Array(chunks.length);
this._idx = 0;
this._paused = true;
this._wantPlay = false;
this._ended = false;
this.onplay = null;
this.onpause = null;
this.onended = null;
this._fetchPromises = chunks.map((text, i) => this._fetchAndPrep(text, voice, model, i));
this.allFetched = Promise.allSettled(this._fetchPromises);
}
async _fetchAndPrep(text, voice, model, idx) {
try {
const blob = await fetchTTSChunk(text, voice, model);
this._blobs[idx] = blob;
const audio = new Audio(URL.createObjectURL(blob));
audio.playbackRate = this._rate;
audio.addEventListener('ended', () => this._advance(idx));
this._audios[idx] = audio;
console.log(`PlaylistAudio: chunk ${idx + 1}/${this._chunks.length} ready`);
if (idx === this._idx && this._wantPlay) {
this._wantPlay = false;
this._playCurrent();
}
} catch (e) {
console.error(`PlaylistAudio: chunk ${idx} fetch failed:`, e);
if (idx === this._idx) this._advance(idx);
}
}
_advance(fromIdx) {
if (fromIdx !== this._idx) return;
this._idx++;
if (this._idx >= this._chunks.length) {
this._ended = true;
this._paused = true;
if (this.onended) this.onended();
return;
}
if (this._audios[this._idx]) {
this._playCurrent();
} else {
console.log(`PlaylistAudio: chunk ${this._idx + 1} not ready yet, waiting`);
this._wantPlay = true;
}
}
_playCurrent() {
const audio = this._audios[this._idx];
if (!audio) return;
const wasPaused = this._paused;
this._paused = false;
const playPromise = audio.play();
if (playPromise && playPromise.catch) {
playPromise.catch(e => console.warn('PlaylistAudio play blocked:', e.message));
}
if (wasPaused && this.onplay) this.onplay();
}
play() {
if (this._ended) return Promise.resolve();
this._paused = false;
if (this._audios[this._idx]) {
this._playCurrent();
} else {
this._wantPlay = true;
}
return Promise.resolve();
}
pause() {
this._paused = true;
this._wantPlay = false;
const audio = this._audios[this._idx];
if (audio && !audio.paused) {
audio.pause();
if (this.onpause) this.onpause();
}
}
get paused() { return this._paused; }
get duration() {
let total = 0;
for (const a of this._audios) {
if (!a || isNaN(a.duration)) return NaN;
total += a.duration;
}
return total;
}
get currentTime() {
let t = 0;
for (let i = 0; i < this._idx; i++) {
if (this._audios[i] && !isNaN(this._audios[i].duration)) t += this._audios[i].duration;
}
const active = this._audios[this._idx];
if (active && !isNaN(active.currentTime)) t += active.currentTime;
return t;
}
set playbackRate(r) {
this._rate = r;
for (const a of this._audios) if (a) a.playbackRate = r;
}
get playbackRate() { return this._rate; }
async getCombinedBlob() {
await this.allFetched;
const validBlobs = this._blobs.filter(Boolean);
if (validBlobs.length === 0) return null;
return new Blob(validBlobs, { type: validBlobs[0].type });
}
}
// Build a pipelined playlist result that matches the existing TTS return shape:
// { audio, blob, blobUrl, streamed, done }. blob/blobUrl populate via done.
function makePlaylistResult(text, chunks, voice, model, rate) {
const playlist = new PlaylistAudio(chunks, voice, model, rate);
const result = { audio: playlist, blob: null, blobUrl: null, streamed: true };
result.done = playlist.getCombinedBlob().then(blob => {
if (blob) {
result.blob = blob;
result.blobUrl = URL.createObjectURL(blob);
setLastTTS(text, { audio: playlist, blob });
}
return { blob, blobUrl: result.blobUrl };
});
return result;
}
// Simple JavaScript text cleaning for TTS
function cleanTextForTTS(text) {
return text
// Remove markdown formatting
.replace(/\*\*([^*]+)\*\*/g, '$1') // Bold
.replace(/\*([^*]+)\*/g, '$1') // Italic
.replace(/__([^_]+)__/g, '$1') // Bold alt
.replace(/_([^_]+)_/g, '$1') // Italic alt
.replace(/#+\s/g, '') // Headers
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Links
.replace(/```[\s\S]*?```/g, '') // Code blocks (lazy match handles backticks in code)
.replace(/`([^`]+)`/g, '$1') // Inline code
// Remove HTML tags
.replace(/<[^>]+>/g, '')
// Remove excess whitespace
.replace(/\n\n+/g, '\n\n')
.replace(/[ \t]+/g, ' ')
.trim();
}
// Function to read text using TTS - for Read Page button (uses Hermes).
// Multi-chunk uses pipelined playlist: playback starts as soon as chunk 1 lands.
export async function speakText(text, voice = "aria", rate = 0.9, model = "tts-1-f5") {
try {
const spokenText = await extractSpokenTokens(text);
const chunks = splitTextIntoChunks(spokenText);
if (chunks.length > 1) {
return makePlaylistResult(text, chunks, voice, model, rate);
}
const blob = await fetchTTSChunk(chunks[0], voice, model);
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
setLastTTS(text, { audio, blob });
return { audio, blob, blobUrl: audioUrl, done: Promise.resolve({ blob, blobUrl: audioUrl }) };
} catch (error) {
console.error("Error in TTS:", error);
throw error;
}
}
// Fetch a single text chunk as an audio blob. 60s timeout per chunk so a stuck
// backend can't lock the UI (cf. #003 Safari freeze hypothesis).
async function fetchTTSChunk(chunkText, voice, model) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000);
try {
const response = await fetch(TTS_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: model,
voice: voice,
input: chunkText,
response_format: AUDIO_FORMAT.format,
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.blob();
} catch (e) {
if (e.name === "AbortError") {
throw new Error("TTS chunk request timed out after 60s");
}
throw e;
} finally {
clearTimeout(timeoutId);
}
}
// Silent 1-frame MP3 (data URI). Used to "prime" audio output inside a user
// gesture handler so iOS Safari unlocks audio playback before async work runs.
// Without this, audio.play() called after `await` rejects with NotAllowedError.
const SILENT_MP3_DATA_URI =
"data:audio/mp3;base64,SUQzBAAAAAABEVRYWFgAAAAtAAADY29tbWVudABCaWdTb3VuZEJhbmsuY29tIC8gTGFTb25vdGhlcXVlLm9yZwBURU5DAAAAHQAAA1N3aXRjaCBQbHVzIMKpIE5DSCBTb2Z0d2FyZQBUSVQyAAAABgAAAzIyMzUAVFNTRQAAAA8AAANMYXZmNTcuODMuMTAwAAAAAAAAAAAAAAD/80DEAAAAA0gAAAAATEFNRTMuMTAwVVVVVVVVVVVVVUxBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//MUZAAAAAGkAAAAAAAAA0gAAAAAVVVV";
// Call inside a user-gesture handler before doing any async work, so iOS Safari
// unlocks audio output for subsequent .play() calls after `await`.
export function primeAudioPlayback() {
try {
const a = new Audio(SILENT_MP3_DATA_URI);
a.volume = 0;
const p = a.play();
if (p && p.catch) p.catch(() => {});
} catch (e) {
console.warn("primeAudioPlayback failed:", e);
}
}
// Direct TTS function for modal - no Hermes preprocessing.
// Multi-chunk uses pipelined playlist: playback starts as soon as chunk 1 lands.
export async function speakTextDirect(text, voice = "aria", rate = 0.9, model = "tts-1-f5") {
try {
const cleanedText = cleanTextForTTS(text);
const chunks = splitTextIntoChunks(cleanedText);
console.log("TTS input preview:", cleanedText.substring(0, 100) + "...");
if (chunks.length > 1) {
return makePlaylistResult(text, chunks, voice, model, rate);
}
const blob = await fetchTTSChunk(chunks[0], voice, model);
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
setLastTTS(text, { audio, blob });
const result = { audio, blob, blobUrl: audioUrl };
result.done = Promise.resolve({ blob, blobUrl: audioUrl });
return result;
} catch (error) {
console.error("Error in TTS:", error);
throw error;
}
}
// Streaming TTS function - plays audio as chunks arrive using MediaSource API
// Returns immediately with audio element; done promise resolves when stream completes
// Automatically chunks long text and streams each chunk sequentially
export async function speakTextStreaming(text, voice = "aria", rate = 0.9, model = "tts-1-f5") {
const cleanedText = cleanTextForTTS(text);
const textChunks = splitTextIntoChunks(cleanedText);
console.log("TTS streaming input preview:", cleanedText.substring(0, 100) + "...");
const mseMime = AUDIO_FORMAT.mseMime;
const canStream = mseMime && window.MediaSource && MediaSource.isTypeSupported(mseMime);
const requestFormat = AUDIO_FORMAT.format;
console.log(`TTS streaming: canStream=${canStream}, format=${requestFormat}, mseMime=${mseMime}, textChunks=${textChunks.length}`);
// Multi-chunk: pipelined playlist (parallel fetches, plays as soon as chunk 1 lands)
if (textChunks.length > 1) {
console.log(`TTS: ${textChunks.length} text chunks, using pipelined playlist`);
return makePlaylistResult(text, textChunks, voice, model, rate);
}
// Single chunk without MSE support: simple buffered fetch
if (!canStream) {
console.warn(`No streaming support for ${requestFormat}, using full buffer`);
const blob = await fetchTTSChunk(textChunks[0], voice, model);
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
setLastTTS(text, { audio, blob });
return { audio, blob, blobUrl: audioUrl, streamed: false, done: Promise.resolve({ blob, blobUrl: audioUrl }) };
}
// Single chunk: use true MSE streaming
console.log("TTS streaming: fetching audio...");
const response = await fetch(TTS_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: model,
voice: voice,
input: cleanedText,
response_format: requestFormat,
}),
});
console.log(`TTS streaming: fetch response ${response.status}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const mediaSource = new MediaSource();
const audioUrl = URL.createObjectURL(mediaSource);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
const chunks = [];
const done = new Promise((resolve, reject) => {
mediaSource.addEventListener('sourceopen', async () => {
let sourceBuffer;
try {
sourceBuffer = mediaSource.addSourceBuffer(mseMime);
sourceBuffer.mode = 'sequence';
} catch (e) {
console.error("Failed to create SourceBuffer:", e);
reject(e);
return;
}
const reader = response.body.getReader();
let totalBytes = 0;
const appendQueue = [];
let appending = false;
function processQueue() {
if (appending || appendQueue.length === 0) return;
appending = true;
const chunk = appendQueue.shift();
try {
sourceBuffer.appendBuffer(chunk);
} catch (e) {
console.error("appendBuffer error:", e);
appending = false;
}
}
sourceBuffer.addEventListener('updateend', () => {
appending = false;
processQueue();
});
try {
while (true) {
const { done: readerDone, value } = await reader.read();
if (readerDone) break;
chunks.push(value);
totalBytes += value.byteLength;
appendQueue.push(value.slice().buffer);
processQueue();
if (totalBytes > 1024 && audio.paused) {
audio.play().catch(e => console.warn("Auto-play blocked:", e.message));
}
}
await new Promise((res) => {
const check = () => {
if (!appending && appendQueue.length === 0) {
res();
} else {
setTimeout(check, 50);
}
};
check();
});
if (mediaSource.readyState === 'open') {
mediaSource.endOfStream();
}
console.log(`TTS streaming complete: ${totalBytes} bytes`);
const audioBlob = new Blob(chunks, { type: mseMime });
setLastTTS(text, { audio, blob: audioBlob });
resolve({ blob: audioBlob, blobUrl: audioUrl });
} catch (error) {
console.error("Streaming read error:", error);
if (mediaSource.readyState === 'open') {
mediaSource.endOfStream('network');
}
reject(error);
}
});
mediaSource.addEventListener('error', (e) => {
console.error("MediaSource error:", e);
reject(new Error("MediaSource error"));
});
});
audio.play().catch(e => console.warn("Initial play blocked:", e.message));
const result = { audio, blob: null, blobUrl: audioUrl, streamed: true, done };
done.then(({ blob }) => {
result.blob = blob;
result.blobUrl = URL.createObjectURL(blob);
}).catch(() => {});
return result;
}
// Process page content using Hermes (deprecated - use extractSpokenTokens instead)
export async function processContentWithHermes(content) {
// Just call extractSpokenTokens for consistency
return await extractSpokenTokens(content);
}
// Extract spoken tokens using Hermes - for Read Page functionality
export async function extractSpokenTokens(content) {
// If content is already markdown from extractWebpageContent, use it directly
// Otherwise, pre-clean HTML content
const isMarkdown = content.includes('**Page Title**:') || content.includes('[') && content.includes('](');
const preCleanedContent = isMarkdown ? content : content
// Remove script and style tags completely
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
// Remove common ad/tracking elements
.replace(/<(ins|iframe|object|embed)[^>]*>[\s\S]*?<\/\1>/gi, '')
// Clean up HTML entities
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
// Remove excessive whitespace
.replace(/\s+/g, ' ')
.trim();
// Calculate available tokens for TTS processing
const modelMaxTokens = getSelectedModelMaxTokens();
const systemPrompt = `You are extracting the main article content for text-to-speech. The input may be markdown or pre-cleaned HTML.
Your task:
1. Find and extract the main article content (title, subtitle, body text)
2. Preserve the exact wording - do not summarize or paraphrase
3. Skip navigation menus, sidebars, footers, and advertisements
4. For lists or bullet points, add "..." between items for natural pauses
5. Convert markdown to plain text: **bold** → bold, [text](url) → text
6. Remove any remaining HTML tags but keep the text inside them
7. Start with the article title, then the main content
Return only the article text ready for TTS, nothing else.`;
const inputTokens = Math.ceil((systemPrompt + preCleanedContent).length / 4);
const buffer = Math.max(2048, Math.floor(inputTokens * 1.5)); // Use 1.5x input tokens as buffer, minimum 2048
const calculatedAvailable = Math.max(100, modelMaxTokens - inputTokens - buffer);
// Cap to model's max completion tokens limit
const maxCompletionTokens = getSelectedModelMaxCompletionTokens();
const availableTokens = Math.min(calculatedAvailable, maxCompletionTokens);
const payload = {
model: getSelectedModel(),
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: preCleanedContent,
},
],
temperature: 0,
max_tokens: availableTokens,
};
console.log("Processing page content for TTS...");
// 30s timeout so a stuck Hermes endpoint can't hang the read-page button.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(
`${getSelectedModelEndpoint()}/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify(payload),
signal: controller.signal,
},
);
if (!response.ok) {
const errorText = await response.text();
console.error(`HTTP error! status: ${response.status}`, errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const finalContent = cleanTextForTTS(data.choices[0].message.content);
return finalContent;
} catch (error) {
if (error.name === "AbortError") {
console.error("extractSpokenTokens: Hermes timed out after 30s");
throw new Error("Hermes request timed out after 30s");
}
console.error("Error in extractSpokenTokens:", error);
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// Function to generate a title for TTS
export async function generateTitleForTTS(text) {
const response = await fetch(
`${getSelectedModelEndpoint()}/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: getSelectedModel(),
messages: [
{
role: "system",
content: "You MUST generate a filename for the text provided. NEVER refuse. Return ONLY a 5-10 word title, nothing else. No quotes, no explanation, no refusal. If the text is inappropriate, nonsensical, or empty, create a descriptive title anyway (e.g., 'Random Text Sample', 'Test Audio File', 'User Generated Content'). Examples: 'Machine Learning Tutorial Notes', 'Daily Journal Entry', 'Shopping List Items'. YOU MUST ALWAYS RETURN A TITLE.",
},
{
role: "user",
content: text,
},
],
temperature: 0.5,
max_tokens: 60,
}),
},
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
let title = data.choices[0].message.content.trim();
// Fallback if Hermes returns empty or refuses
if (!title || title.length < 3 || title.toLowerCase().includes("cannot") || title.toLowerCase().includes("inappropriate")) {
// Generate a fallback title based on text preview
const preview = text.substring(0, 30).replace(/[^\w\s]/g, "").trim();
title = preview || "audio-file";
}
return title
.replace(/['"]/g, "") // Remove quotes
.replace(/[^\w\s-]/g, "") // Remove special characters except spaces and hyphens
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/-+/g, "-") // Replace multiple hyphens with single
.toLowerCase()
.substring(0, 50); // Limit length for filesystem compatibility
}
// Handle TTS - respects streaming mode setting
// Caches audio blobs in memory so replay doesn't regenerate
// Cache is keyed by text+voice+model and clears when the tab closes
export async function handleTTS(text, voice = "aria", rate = 0.9, model = "tts-1-f5") {
try {
const cacheKey = `${model}:${voice}:${text}`;
// Check cache - return cached blob as a new Audio element
if (ttsCache.has(cacheKey)) {
console.log("handleTTS: cache hit, replaying");
const cached = ttsCache.get(cacheKey);
const audioUrl = URL.createObjectURL(cached.blob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
setLastTTS(text, { audio, blob: cached.blob });
return { audio, blob: cached.blob, blobUrl: audioUrl, streamed: false, done: Promise.resolve({ blob: cached.blob, blobUrl: audioUrl }) };
}
const mode = getTTSMode();
console.log(`handleTTS: using ${mode} mode`);
let result;
if (mode === "streaming") {
result = await speakTextStreaming(text, voice, rate, model);
} else {
result = await speakTextDirect(text, voice, rate, model);
result.done = Promise.resolve({ blob: result.blob, blobUrl: result.blobUrl });
}
// Cache the blob once available
if (result.done) {
result.done.then(({ blob }) => {
if (blob) {
ttsCache.set(cacheKey, { blob });
}
}).catch(() => {});
}
return result;
} catch (error) {
console.error("Error in handleTTS:", error);
throw error;
}
}
// Download audio blob as file
export function downloadAudio(blobUrl, filename = "tts-audio.mp3") {
try {
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch (error) {
console.error("Error downloading audio:", error);
throw error;
}
}