750 lines
24 KiB
JavaScript
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(/ /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 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;
|
|
}
|
|
}
|