uncloseai.com/public/src/tts-modal.js

597 lines
20 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 { initializeChunkFiveFont } from "./ui-themes.js";
import { getUIText } from "./ui-translations.js";
import { handleTTS, downloadAudio, generateTitleForTTS, getTTSMode, setTTSMode, primeAudioPlayback, normalizeModelVoice } from "./tts.js";
import { VOICES_API_URL } from "./config.js";
// Function to fetch and populate voice dropdown
async function populateVoiceDropdown(voiceSelection) {
// Check cache first (1 minute expiration)
const cacheKey = 'uncloseai_voices_cache';
const cacheTimeKey = 'uncloseai_voices_cache_time';
const cacheVersionKey = 'uncloseai_voices_cache_version';
const cacheExpiration = 60000; // 1 minute in milliseconds
const currentVersion = '4'; // Increment this when voice models change (v4: 42+ built-in cloned voices)
try {
const cachedTime = localStorage.getItem(cacheTimeKey);
const cachedVoices = localStorage.getItem(cacheKey);
const cachedVersion = localStorage.getItem(cacheVersionKey);
// Check if cache is valid (not expired AND correct version)
if (cachedTime && cachedVoices && cachedVersion === currentVersion) {
const age = Date.now() - parseInt(cachedTime, 10);
if (age < cacheExpiration) {
console.log('Using cached voices data (v' + currentVersion + ')');
const voices = JSON.parse(cachedVoices);
renderVoiceOptions(voiceSelection, voices);
return voices;
}
} else if (cachedVersion !== currentVersion) {
console.log('Cache version mismatch - invalidating old cache');
// Clear old cache
localStorage.removeItem(cacheKey);
localStorage.removeItem(cacheTimeKey);
localStorage.removeItem(cacheVersionKey);
}
} catch (error) {
console.warn('Cache read failed:', error);
}
// Fetch fresh data
try {
console.log('Fetching voices from API...');
const response = await fetch(VOICES_API_URL);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Transform API response format to frontend format
// API returns: {data: [{id: "tts-1-qwen", voices: ["alloy", ...]}]}
// Frontend expects: [{model: "tts-1-qwen", voice: "alloy"}, ...]
let voices = [];
if (data.data && Array.isArray(data.data)) {
data.data.forEach(modelInfo => {
if (modelInfo.voices && Array.isArray(modelInfo.voices)) {
modelInfo.voices.forEach(voice => {
voices.push({ model: modelInfo.id, voice: voice });
});
}
});
} else if (data.voices && Array.isArray(data.voices)) {
// Legacy format support
voices = data.voices;
}
console.log(`Loaded ${voices.length} voices from API`);
// Cache the response with version
try {
localStorage.setItem(cacheKey, JSON.stringify(voices));
localStorage.setItem(cacheTimeKey, Date.now().toString());
localStorage.setItem(cacheVersionKey, currentVersion);
} catch (error) {
console.warn('Cache write failed:', error);
}
renderVoiceOptions(voiceSelection, voices);
return voices;
} catch (error) {
console.error('Failed to fetch voices:', error);
// Fallback to F5-TTS cloned-voice catalog (40 voices, matches sidecar)
const F5_VOICES = [
'aria', 'clara', 'elena', 'grace', 'hazel', 'iris', 'luna', 'maya',
'ruby', 'sage', 'sofia', 'amber', 'brooke', 'cora', 'diana', 'eden',
'faye', 'gemma', 'hope', 'ivy', 'atlas', 'caleb', 'felix', 'hugo',
'jasper', 'kai', 'leo', 'marcus', 'owen', 'theo', 'archer', 'blake',
'cole', 'dane', 'ezra', 'finn', 'grant', 'heath', 'ivan', 'jude'
];
const defaultVoices = F5_VOICES.map(voice => ({ model: 'tts-1-f5', voice }));
renderVoiceOptions(voiceSelection, defaultVoices);
return defaultVoices;
}
}
// Function to render voice options in the dropdown
function renderVoiceOptions(voiceSelection, voices) {
voiceSelection.innerHTML = ''; // Clear existing options
// Get saved voice preference (vault first, fallback to default)
let savedVoice = 'tts-1-f5:atlas'; // Default
try {
if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) {
savedVoice = window.UncloseVault.get('uncloseai_selected_voice', 'tts-1-f5:atlas');
}
} catch (error) {
console.warn('Failed to read voice preference:', error);
}
// Group voices by model
const groupedVoices = {};
voices.forEach(v => {
if (!groupedVoices[v.model]) {
groupedVoices[v.model] = [];
}
groupedVoices[v.model].push(v.voice);
});
// Create radio buttons grouped by model
Object.keys(groupedVoices).sort().forEach(model => {
// Add model label
const modelLabel = document.createElement('div');
modelLabel.className = 'tts-model-label';
modelLabel.textContent = model;
voiceSelection.appendChild(modelLabel);
// Add voices for this model
groupedVoices[model].forEach(voice => {
const label = document.createElement('label');
label.className = 'tts-voice-label';
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = 'tts-voice';
radio.value = `${model}:${voice}`;
// Check if this is the saved voice
if (`${model}:${voice}` === savedVoice) {
radio.checked = true;
}
// Save selection on change (through vault)
radio.addEventListener('change', () => {
try {
if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) {
window.UncloseVault.set('uncloseai_selected_voice', radio.value);
} else {
console.warn('Vault locked - voice preference not persisted');
}
} catch (error) {
console.warn('Failed to save voice preference:', error);
}
});
label.appendChild(radio);
label.appendChild(document.createTextNode(voice));
voiceSelection.appendChild(label);
});
});
// If no voice is checked, check the first one
if (!voiceSelection.querySelector('input[type="radio"]:checked')) {
const firstRadio = voiceSelection.querySelector('input[type="radio"]');
if (firstRadio) {
firstRadio.checked = true;
}
}
}
export function openTTSModal() {
// Load appropriate CSS based on whether we're using PicoCSS or built-in styles
// Detect if PicoCSS is actually present on the page
const hasPicoCSS = document.querySelector('link[href*="pico"]') !== null;
// Load appropriate CSS based on actual PicoCSS presence
const cssFile = hasPicoCSS ? 'uncloseai-modal-pico.css' : 'uncloseai-modal-builtin.css';
if (!document.querySelector(`link[href*="${cssFile}"]`)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://uncloseai.com/src/${cssFile}`;
document.head.appendChild(link);
}
// Ensure ChunkFive font is loaded
initializeChunkFiveFont();
// Check if Hermes modal is open to set appropriate z-index
const hermesModal = document.getElementById("uncloseai-embedded-modal");
const zIndex = hermesModal ? "2001" : "1001";
const modal = document.createElement("dialog");
modal.id = "tts-modal";
modal.style.zIndex = zIndex;
document.body.appendChild(modal);
const article = document.createElement("article");
article.className = "tts-modal-article";
const header = document.createElement("header");
header.className = "tts-modal-header";
const h1 = document.createElement("h1");
h1.innerHTML = `<span style="font-family: 'ChunkFiveRegular', monospace;">uncloseai.</span> ${getUIText("ttsModalTitle")}`;
header.appendChild(h1);
const closeButton = document.createElement("button");
closeButton.textContent = "X";
closeButton.className = "close-btn";
closeButton.onclick = () => {
modal.close();
document.body.removeChild(modal);
};
header.appendChild(closeButton);
const textArea = document.createElement("textarea");
textArea.className = "tts-modal-textarea";
textArea.placeholder = getUIText("ttsPlaceholder");
// Voice selection - dynamically populated from API
const voiceSelection = document.createElement("div");
voiceSelection.className = "tts-voice-container";
// Populate voice options dynamically
populateVoiceDropdown(voiceSelection);
// Speed control
const speedControl = document.createElement("div");
speedControl.className = "tts-speed-control";
const speedLabel = document.createElement("label");
const speedValue = document.createElement("span");
speedValue.textContent = "0.9";
speedLabel.textContent = `${getUIText("speedLabel")} ${speedValue.textContent}`;
speedLabel.setAttribute("data-i18n", "speedLabel");
const speedSlider = document.createElement("input");
speedSlider.type = "range";
speedSlider.min = "0.25";
speedSlider.max = "2.0";
speedSlider.step = "0.05";
speedSlider.value = "0.9";
speedSlider.oninput = () => {
speedValue.textContent = speedSlider.value;
speedLabel.textContent = `${getUIText("speedLabel")} ${speedSlider.value}`;
};
speedControl.appendChild(speedLabel);
speedControl.appendChild(speedSlider);
// Streaming mode toggle
const modeControl = document.createElement("div");
modeControl.className = "tts-mode-control";
modeControl.style.cssText = "display:flex;align-items:center;gap:8px;margin:8px 0;font-size:0.9em;";
const modeLabel = document.createElement("span");
modeLabel.textContent = "Playback:";
modeLabel.style.fontWeight = "bold";
const modeBuffered = document.createElement("label");
modeBuffered.style.cssText = "display:flex;align-items:center;gap:4px;cursor:pointer;";
const radioBuffered = document.createElement("input");
radioBuffered.type = "radio";
radioBuffered.name = "tts-mode";
radioBuffered.value = "buffered";
radioBuffered.checked = getTTSMode() === "buffered";
modeBuffered.appendChild(radioBuffered);
modeBuffered.appendChild(document.createTextNode("Buffered"));
const modeStreaming = document.createElement("label");
modeStreaming.style.cssText = "display:flex;align-items:center;gap:4px;cursor:pointer;";
const radioStreaming = document.createElement("input");
radioStreaming.type = "radio";
radioStreaming.name = "tts-mode";
radioStreaming.value = "streaming";
radioStreaming.checked = getTTSMode() === "streaming";
modeStreaming.appendChild(radioStreaming);
modeStreaming.appendChild(document.createTextNode("Streaming"));
radioBuffered.addEventListener("change", () => { if (radioBuffered.checked) setTTSMode("buffered"); });
radioStreaming.addEventListener("change", () => { if (radioStreaming.checked) setTTSMode("streaming"); });
modeControl.appendChild(modeLabel);
modeControl.appendChild(modeBuffered);
modeControl.appendChild(modeStreaming);
const playButton = document.createElement("button");
playButton.textContent = getUIText("playText");
playButton.className = "tts-play-btn";
const resultDiv = document.createElement("div");
resultDiv.id = "tts-result";
resultDiv.className = "tts-result";
// Content container with padding
const contentContainer = document.createElement("div");
contentContainer.className = "tts-modal-content";
contentContainer.appendChild(textArea);
contentContainer.appendChild(voiceSelection);
contentContainer.appendChild(speedControl);
contentContainer.appendChild(modeControl);
contentContainer.appendChild(playButton);
contentContainer.appendChild(resultDiv);
article.appendChild(header);
article.appendChild(contentContainer);
modal.appendChild(article);
// Safari <15.4 lacks native <dialog>; fall back to a positioned overlay.
try {
modal.showModal();
} catch (e) {
console.warn("dialog.showModal() unsupported, using fallback overlay:", e);
modal.setAttribute("open", "");
modal.style.position = "fixed";
modal.style.top = "50%";
modal.style.left = "50%";
modal.style.transform = "translate(-50%, -50%)";
}
// Play button handler
playButton.onclick = async () => {
// Prime iOS Safari audio output before any async work (lost gesture fix).
primeAudioPlayback();
const text = textArea.value.trim();
if (!text) {
alert(getUIText("pleaseEnterText"));
return;
}
const selectedRadio = document.querySelector('input[name="tts-voice"]:checked');
if (!selectedRadio) {
alert("Please select a voice first");
return;
}
const selectedValue = selectedRadio.value;
const speed = parseFloat(speedSlider.value);
// Parse model:voice format (e.g., "tts-1-f5:aria" or legacy "aria")
// Normalize legacy/qwen entries so stale vault data doesn't 503.
let model = 'tts-1-f5';
let voice = selectedValue;
if (selectedValue.includes(':')) {
const parts = selectedValue.split(':');
model = parts[0];
voice = parts[1];
}
({ model, voice } = normalizeModelVoice(model, voice));
try {
const currentMode = getTTSMode();
playButton.textContent = currentMode === "streaming" ? "Streaming..." : getUIText("processingText");
playButton.disabled = true;
const result = await handleTTS(text, voice, speed, model);
if (!result.audio) return;
const isStreaming = result.streamed === true;
console.log(`TTS result: streamed=${isStreaming}`);
resultDiv.classList.add("show");
resultDiv.innerHTML = "";
// Use the audio element from the result directly (critical for streaming -
// it's already playing via MediaSource, creating a new Audio would lose the stream)
const audio = result.audio;
// For buffered mode, the audio isn't playing yet, so set it up
if (!isStreaming) {
audio.preload = "auto";
}
// Create custom control container
const controlsContainer = document.createElement("div");
controlsContainer.className = "tts-audio-controls";
// Create play/pause button
const playPauseBtn = document.createElement("button");
playPauseBtn.textContent = isStreaming ? "⏸️ Streaming..." : "⏸️ Pause";
// Create progress bar
const progressContainer = document.createElement("div");
progressContainer.className = "tts-progress-container";
const progressBar = document.createElement("div");
progressBar.className = "tts-progress-bar";
progressContainer.appendChild(progressBar);
// Create time display
const timeDisplay = document.createElement("span");
timeDisplay.textContent = "0:00 / 0:00";
timeDisplay.className = "tts-time-display";
// Create download button (disabled until stream completes)
const downloadBtn = document.createElement("button");
downloadBtn.className = "download-btn";
let downloadBlobUrl = result.blobUrl;
if (isStreaming && !result.blob) {
downloadBtn.textContent = "💾 Loading...";
downloadBtn.disabled = true;
} else {
downloadBtn.textContent = "💾 Download";
}
downloadBtn.onclick = async () => {
if (!downloadBlobUrl) return;
try {
const filename = await generateTitleForTTS(text);
downloadAudio(downloadBlobUrl, `${filename}.mp3`);
} catch (error) {
console.error("Error generating filename:", error);
downloadAudio(downloadBlobUrl, "tts-audio.mp3");
}
};
// Assemble controls
controlsContainer.appendChild(playPauseBtn);
controlsContainer.appendChild(progressContainer);
controlsContainer.appendChild(timeDisplay);
controlsContainer.appendChild(downloadBtn);
resultDiv.appendChild(controlsContainer);
// Track state
let isPlaying = !audio.paused;
let streamDone = !isStreaming; // buffered mode is already "done"
const formatTime = (seconds) => {
if (!isFinite(seconds)) return "?:??";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const updateProgress = () => {
if (audio.duration && isFinite(audio.duration)) {
const progress = (audio.currentTime / audio.duration) * 100;
progressBar.style.width = `${progress}%`;
timeDisplay.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
} else if (isStreaming) {
// During streaming, duration may not be known yet
timeDisplay.textContent = `${formatTime(audio.currentTime)} / streaming...`;
}
};
const updatePlayPauseBtn = () => {
if (isPlaying) {
playPauseBtn.textContent = streamDone ? "⏸️ Pause" : "⏸️ Streaming...";
} else {
playPauseBtn.textContent = "▶️ Play";
}
};
playPauseBtn.onclick = () => {
if (isPlaying) {
audio.pause();
isPlaying = false;
} else {
audio.play();
isPlaying = true;
}
updatePlayPauseBtn();
};
// Progress bar click handler
progressContainer.onclick = (e) => {
if (audio.duration && isFinite(audio.duration)) {
const rect = progressContainer.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickRatio = clickX / rect.width;
audio.currentTime = clickRatio * audio.duration;
}
};
// Audio event listeners
audio.addEventListener('loadedmetadata', () => {
if (isFinite(audio.duration)) {
timeDisplay.textContent = `0:00 / ${formatTime(audio.duration)}`;
}
});
audio.addEventListener('timeupdate', updateProgress);
audio.addEventListener('ended', () => {
playPauseBtn.textContent = "▶️ Replay";
isPlaying = false;
progressBar.style.width = '0%';
audio.currentTime = 0;
});
audio.addEventListener('error', (e) => {
console.error('Audio playback error:', e);
playPauseBtn.textContent = "❌ Error";
playPauseBtn.disabled = true;
const errorMsg = document.createElement('div');
errorMsg.className = "tts-error-message";
errorMsg.textContent = 'Audio failed to load. Please try again or check your connection.';
controlsContainer.appendChild(errorMsg);
});
audio.addEventListener('abort', (e) => {
console.warn('Audio loading was aborted:', e);
if (isPlaying) {
playPauseBtn.textContent = "▶️ Retry";
isPlaying = false;
}
});
audio.addEventListener('stalled', (e) => {
console.warn('Audio loading stalled:', e);
if (!streamDone) {
playPauseBtn.textContent = "⏳ Buffering...";
}
});
// Wait for stream to complete in background (doesn't block UI)
result.done.then(({ blob, blobUrl }) => {
streamDone = true;
downloadBlobUrl = blobUrl;
downloadBtn.textContent = "💾 Download";
downloadBtn.disabled = false;
console.log("Stream done, download ready");
// Update button text now that stream is complete
if (isPlaying) {
playPauseBtn.textContent = "⏸️ Pause";
}
}).catch((err) => {
console.error("Stream failed:", err);
downloadBtn.textContent = "💾 Failed";
});
// For buffered mode, auto-play; for streaming, it's already playing
if (!isStreaming) {
audio.play().then(() => {
isPlaying = true;
updatePlayPauseBtn();
}).catch((e) => {
console.warn('Auto-play failed:', e.name, e.message);
isPlaying = false;
playPauseBtn.textContent = "▶️ Play";
if (e.name === 'NotSupportedError') {
playPauseBtn.textContent = "❌ Unsupported";
playPauseBtn.disabled = true;
}
});
} else {
// Streaming mode - audio is already playing
updatePlayPauseBtn();
}
// Re-enable the generate button now that controls are shown
playButton.textContent = getUIText("playText");
playButton.disabled = false;
} catch (error) {
alert(getUIText("ttsFailed", { error: error.message }));
playButton.textContent = getUIText("playText");
playButton.disabled = false;
}
};
// Focus the textarea
setTimeout(() => {
textArea.focus();
}, 100);
// Expose globally
window.closeTTSModal = () => {
modal.close();
document.body.removeChild(modal);
};
// Clean up when closed
modal.addEventListener("close", () => {
if (modal.parentNode) {
document.body.removeChild(modal);
}
});
}