uncloseai.com/src/tts.js
Russell Ballestrini 4dc9568079 feat: improve extractSpokenTokens with JavaScript pre and post-processing
- Pre-clean HTML before sending to Hermes (remove scripts, styles, ads, iframes)
- Decode HTML entities (  & etc.) before Hermes processing
- Simplified, clearer system prompt focused on extraction tasks
- Post-process Hermes output with cleanTextForTTS() for final cleanup
- Reduces Hermes workload and improves extraction quality
- Three-stage pipeline: JS pre-clean → Hermes extraction → JS post-clean
2025-07-03 20:23:52 -04:00

313 lines
8.6 KiB
JavaScript

// Text-to-speech functionality
import { API_KEY, MODEL, setLastTTS, TTS_API_URL } from "./config.js";
import { getSelectedModel, getSelectedModelEndpoint } 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
export async function processContentWithHermes(content) {
const payload = {
model: getSelectedModel(),
messages: [
{
role: "system",
content: `Extract the exact main content of the article.
Avoid reading ads.
Do not change or summarize content. Return the same words verbatim.
When you encounter a list or bullets, add . . . to make the TTS pause between items.
Try to stay as close to the truth of the original version as possible. Start with the title of the post and then jump into it.
Do not mention the TTS stream just do the work!
Remember your only goal is to extract the entire & exact main content of the article.
Ready? Breath and then return a stream of tokens to be used in a TTS system.
`,
},
{
role: "user",
content: content,
},
],
temperature: 0,
max_tokens: 76000,
};
console.log(
"Sending payload to LLM for spoken tokens:",
JSON.stringify(payload, null, 2),
);
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();
return data.choices[0].message.content;
} catch (error) {
console.error("Error in processContentWithHermes:", error);
throw error;
}
}
// Extract spoken tokens using Hermes - for Read Page functionality
export async function extractSpokenTokens(content) {
// Pre-clean content with JavaScript to help Hermes
const preCleanedContent = 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();
const payload = {
model: getSelectedModel(),
messages: [
{
role: "system",
content: `You are extracting the main article content for text-to-speech. The input has been pre-cleaned of scripts and ads.
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. Remove any remaining HTML tags but keep the text inside them
6. Start with the article title, then the main content
Return only the article text ready for TTS, nothing else.`,
},
{
role: "user",
content: preCleanedContent,
},
],
temperature: 0,
max_tokens: 76000,
};
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: MODEL,
messages: [
{
role: "system",
content: "Generate a concise 5-10 word filename title for the following text. Return only the title words, no quotes, no explanation. Focus on the main topic or key concept. Example: 'Machine Learning Basics Tutorial' or 'Recipe For Chocolate Chip Cookies' or 'JavaScript Array Methods Guide'.",
},
{
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();
return data.choices[0].message.content
.trim()
.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;
}
}