beeta/frontend/src/lib/chat/ttsStore.js

221 lines
5.5 KiB
JavaScript
Raw Normal View History

2026-01-05 22:54:27 -05:00
import { writable, get } from 'svelte/store';
import { browser } from '$app/environment';
// TTS enabled state (default: false)
export const ttsEnabled = writable(false);
// TTS settings
export const ttsSettings = writable({
voice: null, // SpeechSynthesisVoice object
voiceName: '', // Voice name for persistence
rate: 1, // 0.5 - 2
pitch: 1, // 0.5 - 2
volume: 1 // 0 - 1
});
// User filter: 'all' | 'guests' | 'registered'
export const ttsUserFilter = writable('all');
// Set of muted user IDs
export const ttsMutedUsers = writable(new Set());
// Available voices from browser
export const availableVoices = writable([]);
// Current realm ID for per-realm settings
let currentRealmId = null;
// Initialize voices from browser
export function initVoices() {
if (!browser || !window.speechSynthesis) return;
const loadVoices = () => {
const voices = window.speechSynthesis.getVoices();
availableVoices.set(voices);
// Restore saved voice if available
const settings = get(ttsSettings);
if (settings.voiceName && voices.length > 0) {
const savedVoice = voices.find(v => v.name === settings.voiceName);
if (savedVoice) {
ttsSettings.update(s => ({ ...s, voice: savedVoice }));
}
}
};
// Chrome needs this event, Firefox loads immediately
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = loadVoices;
}
loadVoices();
}
// Set voice by name
export function setVoice(voiceName) {
const voices = get(availableVoices);
const voice = voices.find(v => v.name === voiceName);
if (voice) {
ttsSettings.update(s => ({ ...s, voice, voiceName }));
saveSettings();
}
}
// Check if message should be spoken
function shouldSpeak(message) {
if (!get(ttsEnabled)) return false;
// Check if user is muted
const mutedUsers = get(ttsMutedUsers);
if (mutedUsers.has(message.userId)) return false;
// Check user filter
const filter = get(ttsUserFilter);
if (filter === 'guests' && !message.isGuest) return false;
if (filter === 'registered' && message.isGuest) return false;
return true;
}
// Strip HTML/markdown and clean text for TTS
function cleanTextForTTS(text) {
if (!text) return '';
// Remove HTML tags
let clean = text.replace(/<[^>]*>/g, '');
// Remove markdown formatting
clean = clean.replace(/\*\*(.+?)\*\*/g, '$1'); // bold
clean = clean.replace(/\*(.+?)\*/g, '$1'); // italic
clean = clean.replace(/__(.+?)__/g, '$1'); // underline
clean = clean.replace(/~~(.+?)~~/g, '$1'); // strikethrough
clean = clean.replace(/`(.+?)`/g, '$1'); // code
// Remove URLs
clean = clean.replace(/https?:\/\/\S+/g, 'link');
// Limit length to prevent very long TTS
if (clean.length > 200) {
clean = clean.substring(0, 200) + '...';
}
return clean.trim();
}
// Speak a message
export function speakMessage(message) {
if (!browser || !window.speechSynthesis) return;
if (!shouldSpeak(message)) return;
const settings = get(ttsSettings);
const text = cleanTextForTTS(message.content);
if (!text) return;
// Add username prefix
const fullText = `${message.username} says: ${text}`;
const utterance = new SpeechSynthesisUtterance(fullText);
if (settings.voice) {
utterance.voice = settings.voice;
}
utterance.rate = settings.rate;
utterance.pitch = settings.pitch;
utterance.volume = settings.volume;
window.speechSynthesis.speak(utterance);
}
// Toggle mute for a user
export function toggleUserMute(userId, username) {
ttsMutedUsers.update(muted => {
const newMuted = new Set(muted);
if (newMuted.has(userId)) {
newMuted.delete(userId);
} else {
newMuted.add(userId);
}
return newMuted;
});
saveSettings();
}
// Check if user is muted
export function isUserMuted(userId) {
return get(ttsMutedUsers).has(userId);
}
// Clear all muted users
export function clearMutedUsers() {
ttsMutedUsers.set(new Set());
saveSettings();
}
// Save settings to localStorage
function saveSettings() {
if (!browser || !currentRealmId) return;
const settings = get(ttsSettings);
const data = {
enabled: get(ttsEnabled),
voiceName: settings.voiceName,
rate: settings.rate,
pitch: settings.pitch,
volume: settings.volume,
userFilter: get(ttsUserFilter),
mutedUsers: Array.from(get(ttsMutedUsers))
};
localStorage.setItem(`tts_settings_${currentRealmId}`, JSON.stringify(data));
}
// Load settings from localStorage
export function loadSettings(realmId) {
currentRealmId = realmId;
if (!browser || !realmId) return;
const saved = localStorage.getItem(`tts_settings_${realmId}`);
if (!saved) {
// Reset to defaults for new realm
ttsEnabled.set(false);
ttsUserFilter.set('all');
ttsMutedUsers.set(new Set());
return;
}
try {
const data = JSON.parse(saved);
ttsEnabled.set(data.enabled || false);
ttsUserFilter.set(data.userFilter || 'all');
ttsMutedUsers.set(new Set(data.mutedUsers || []));
ttsSettings.update(s => ({
...s,
voiceName: data.voiceName || '',
rate: data.rate ?? 1,
pitch: data.pitch ?? 1,
volume: data.volume ?? 1
}));
// Restore voice object if voices are loaded
if (data.voiceName) {
const voices = get(availableVoices);
const voice = voices.find(v => v.name === data.voiceName);
if (voice) {
ttsSettings.update(s => ({ ...s, voice }));
}
}
} catch (e) {
console.error('Failed to load TTS settings:', e);
}
}
// Subscribe to store changes to auto-save
if (browser) {
ttsEnabled.subscribe(() => saveSettings());
ttsSettings.subscribe(() => saveSettings());
ttsUserFilter.subscribe(() => saveSettings());
}