uncloseai.com/src/tts.js

273 lines
8.5 KiB
JavaScript

// Text-to-speech functionality
import { API_KEY, MODEL, setLastTTS, TTS_API_URL } from "./config.js";
import { getSelectedModel, getSelectedModelEndpoint, getSelectedModelMaxTokens } from "./models.js";
// 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(/`([^`]+)`/g, '$1') // Inline code
.replace(/```[^`]*```/g, '') // Code blocks
// 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)
export async function speakText(text, voice = "alloy", rate = 0.9) {
try {
// Preprocess the text using Hermes to get spoken tokens
const spokenText = await extractSpokenTokens(text);
const response = await fetch(TTS_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: "tts-1",
voice: voice,
input: spokenText,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
// Store the last TTS result
setLastTTS(text, { audio, blob: audioBlob });
return { audio, blob: audioBlob, blobUrl: audioUrl };
} catch (error) {
console.error("Error in TTS:", error);
throw error;
}
}
// Direct TTS function for modal - no Hermes preprocessing
export async function speakTextDirect(text, voice = "alloy", rate = 0.9) {
try {
// Clean text with simple JavaScript instead of Hermes
const cleanedText = cleanTextForTTS(text);
console.log("TTS input preview:", cleanedText.substring(0, 100) + "...");
const response = await fetch(TTS_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: "tts-1",
voice: voice,
input: cleanedText,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
// Store the last TTS result
setLastTTS(text, { audio, blob: audioBlob });
return { audio, blob: audioBlob, blobUrl: audioUrl };
} catch (error) {
console.error("Error in TTS:", error);
throw error;
}
}
// 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 availableTokens = Math.max(100, modelMaxTokens - inputTokens - buffer);
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...");
try {
const response = await fetch(
`${getSelectedModelEndpoint()}/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify(payload),
},
);
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();
// Post-process Hermes output with JavaScript cleaning
const finalContent = cleanTextForTTS(data.choices[0].message.content);
return finalContent;
} catch (error) {
console.error("Error in extractSpokenTokens:", error);
throw error;
}
}
// 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 from element (for modal usage - uses direct TTS without Hermes)
export async function handleTTS(text, voice = "alloy", rate = 0.9) {
try {
const result = await speakTextDirect(text, voice, rate);
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;
}
}