273 lines
8.5 KiB
JavaScript
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(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/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;
|
|
}
|
|
}
|