11 changed files with 1710 additions and 16 deletions
@ -0,0 +1,364 @@
@@ -0,0 +1,364 @@
|
||||
<script lang="ts"> |
||||
import { onMount, onDestroy } from 'svelte'; |
||||
import { getTTSService } from '../../services/tts/tts-service.js'; |
||||
import { extractTextForTTS } from '../../services/tts/text-extractor.js'; |
||||
import type { TTSVoice, TTSState } from '../../services/tts/types.js'; |
||||
import Icon from '../ui/Icon.svelte'; |
||||
|
||||
interface Props { |
||||
text: string | HTMLElement; // Text or HTML element to read |
||||
autoStart?: boolean; // Auto-start playback |
||||
} |
||||
|
||||
let { text, autoStart = false }: Props = $props(); |
||||
|
||||
const ttsService = getTTSService(); |
||||
let state = $state<TTSState>('idle'); |
||||
let voices = $state<TTSVoice[]>([]); |
||||
let selectedVoice = $state<TTSVoice | null>(null); |
||||
let speed = $state(1.0); |
||||
let volume = $state(1.0); |
||||
let progress = $state(0); |
||||
let extractedText = $state(''); |
||||
let available = $state(false); |
||||
|
||||
onMount(async () => { |
||||
// Check availability |
||||
available = await ttsService.isAvailable(); |
||||
if (!available) { |
||||
return; |
||||
} |
||||
|
||||
// Load voices |
||||
try { |
||||
const loadedVoices = await ttsService.getVoices(); |
||||
voices = loadedVoices; |
||||
// Select default voice (prefer English) |
||||
const defaultVoice = loadedVoices.find(v => v.lang.startsWith('en')) || loadedVoices[0]; |
||||
if (defaultVoice) { |
||||
selectedVoice = defaultVoice; |
||||
} |
||||
} catch (error) { |
||||
console.error('Failed to load voices:', error); |
||||
} |
||||
|
||||
// Extract text |
||||
if (typeof text === 'string') { |
||||
extractedText = extractTextForTTS(text); |
||||
} else { |
||||
extractedText = extractTextForTTS(text); |
||||
} |
||||
|
||||
// Set up callbacks |
||||
ttsService.setCallbacks({ |
||||
onStateChange: (newState) => { |
||||
state = newState; |
||||
}, |
||||
onProgress: (prog) => { |
||||
progress = prog; |
||||
}, |
||||
onError: (error) => { |
||||
console.error('TTS error:', error); |
||||
}, |
||||
onEnd: () => { |
||||
state = 'idle'; |
||||
progress = 0; |
||||
} |
||||
}); |
||||
|
||||
// Auto-start if requested |
||||
if (autoStart && extractedText) { |
||||
await handlePlay(); |
||||
} |
||||
}); |
||||
|
||||
onDestroy(() => { |
||||
ttsService.stop(); |
||||
}); |
||||
|
||||
async function handlePlay() { |
||||
if (!extractedText) return; |
||||
|
||||
if (state === 'paused') { |
||||
await ttsService.resume(); |
||||
} else { |
||||
await ttsService.speak(extractedText, { |
||||
voice: selectedVoice || undefined, |
||||
speed, |
||||
volume |
||||
}); |
||||
} |
||||
} |
||||
|
||||
async function handlePause() { |
||||
await ttsService.pause(); |
||||
} |
||||
|
||||
async function handleStop() { |
||||
await ttsService.stop(); |
||||
progress = 0; |
||||
} |
||||
|
||||
function handleSpeedChange(e: Event) { |
||||
const target = e.target as HTMLInputElement; |
||||
speed = parseFloat(target.value); |
||||
// If playing, restart with new speed |
||||
if (state === 'playing' || state === 'paused') { |
||||
handleStop(); |
||||
setTimeout(() => handlePlay(), 100); |
||||
} |
||||
} |
||||
|
||||
function handleVolumeChange(e: Event) { |
||||
const target = e.target as HTMLInputElement; |
||||
volume = parseFloat(target.value); |
||||
// If playing, restart with new volume |
||||
if (state === 'playing' || state === 'paused') { |
||||
handleStop(); |
||||
setTimeout(() => handlePlay(), 100); |
||||
} |
||||
} |
||||
|
||||
function handleVoiceChange(e: Event) { |
||||
const target = e.target as HTMLSelectElement; |
||||
const voiceId = target.value; |
||||
selectedVoice = voices.find(v => v.id === voiceId) || null; |
||||
// If playing, restart with new voice |
||||
if (state === 'playing' || state === 'paused') { |
||||
handleStop(); |
||||
setTimeout(() => handlePlay(), 100); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
{#if available} |
||||
<div class="tts-controls"> |
||||
<div class="tts-controls-main"> |
||||
<!-- Play/Pause/Stop buttons --> |
||||
<div class="tts-buttons"> |
||||
{#if state === 'playing'} |
||||
<button class="tts-button" onclick={handlePause} aria-label="Pause"> |
||||
⏸ Pause |
||||
</button> |
||||
{:else if state === 'paused'} |
||||
<button class="tts-button" onclick={handlePlay} aria-label="Resume"> |
||||
▶ Resume |
||||
</button> |
||||
{:else} |
||||
<button class="tts-button" onclick={handlePlay} aria-label="Play" disabled={!extractedText}> |
||||
▶ Play |
||||
</button> |
||||
{/if} |
||||
|
||||
{#if state === 'playing' || state === 'paused'} |
||||
<button class="tts-button" onclick={handleStop} aria-label="Stop"> |
||||
⏹ Stop |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
|
||||
<!-- Speed control --> |
||||
<div class="tts-control-group"> |
||||
<label for="tts-speed" class="tts-label">Speed</label> |
||||
<input |
||||
id="tts-speed" |
||||
type="range" |
||||
min="0.5" |
||||
max="2.0" |
||||
step="0.1" |
||||
value={speed} |
||||
oninput={handleSpeedChange} |
||||
class="tts-slider" |
||||
/> |
||||
<span class="tts-value">{speed.toFixed(1)}x</span> |
||||
</div> |
||||
|
||||
<!-- Volume control --> |
||||
<div class="tts-control-group"> |
||||
<label for="tts-volume" class="tts-label">Volume</label> |
||||
<input |
||||
id="tts-volume" |
||||
type="range" |
||||
min="0" |
||||
max="1" |
||||
step="0.1" |
||||
value={volume} |
||||
oninput={handleVolumeChange} |
||||
class="tts-slider" |
||||
/> |
||||
<span class="tts-value">{Math.round(volume * 100)}%</span> |
||||
</div> |
||||
|
||||
<!-- Voice selection --> |
||||
{#if voices.length > 0} |
||||
<div class="tts-control-group"> |
||||
<label for="tts-voice" class="tts-label">Voice</label> |
||||
<select |
||||
id="tts-voice" |
||||
value={selectedVoice?.id || ''} |
||||
onchange={handleVoiceChange} |
||||
class="tts-select" |
||||
> |
||||
{#each voices as voice} |
||||
<option value={voice.id}>{voice.name} ({voice.lang})</option> |
||||
{/each} |
||||
</select> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<!-- Progress indicator --> |
||||
{#if state === 'playing' || state === 'paused'} |
||||
<div class="tts-progress"> |
||||
<div class="tts-progress-bar" style="width: {progress * 100}%"></div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{:else} |
||||
<div class="tts-unavailable"> |
||||
<p>Text-to-speech is not available in your browser.</p> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.tts-controls { |
||||
background: var(--fog-post, #ffffff); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
padding: 1rem; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
:global(.dark) .tts-controls { |
||||
background: var(--fog-dark-post, #334155); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.tts-controls-main { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 1rem; |
||||
align-items: center; |
||||
} |
||||
|
||||
.tts-buttons { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.tts-button { |
||||
background: var(--fog-surface, #f8fafc); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
padding: 0.5rem; |
||||
cursor: pointer; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
transition: background-color 0.2s; |
||||
color: var(--fog-text, #475569); |
||||
} |
||||
|
||||
.tts-button:hover:not(:disabled) { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
.tts-button:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
:global(.dark) .tts-button { |
||||
background: var(--fog-dark-surface, #1e293b); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
color: var(--fog-dark-text, #cbd5e1); |
||||
} |
||||
|
||||
:global(.dark) .tts-button:hover:not(:disabled) { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.tts-control-group { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.tts-label { |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text, #475569); |
||||
white-space: nowrap; |
||||
} |
||||
|
||||
:global(.dark) .tts-label { |
||||
color: var(--fog-dark-text, #cbd5e1); |
||||
} |
||||
|
||||
.tts-slider { |
||||
width: 100px; |
||||
} |
||||
|
||||
.tts-value { |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text-light, #52667a); |
||||
min-width: 3rem; |
||||
text-align: right; |
||||
} |
||||
|
||||
:global(.dark) .tts-value { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
.tts-select { |
||||
background: var(--fog-surface, #f8fafc); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
padding: 0.25rem 0.5rem; |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text, #475569); |
||||
min-width: 200px; |
||||
} |
||||
|
||||
:global(.dark) .tts-select { |
||||
background: var(--fog-dark-surface, #1e293b); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
color: var(--fog-dark-text, #cbd5e1); |
||||
} |
||||
|
||||
.tts-progress { |
||||
margin-top: 1rem; |
||||
height: 4px; |
||||
background: var(--fog-border, #e5e7eb); |
||||
border-radius: 2px; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .tts-progress { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.tts-progress-bar { |
||||
height: 100%; |
||||
background: var(--fog-accent, #64748b); |
||||
transition: width 0.1s; |
||||
} |
||||
|
||||
:global(.dark) .tts-progress-bar { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.tts-unavailable { |
||||
padding: 1rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
color: var(--fog-text-light, #52667a); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .tts-unavailable { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
|
||||
<script lang="ts"> |
||||
import TTSControls from '../content/TTSControls.svelte'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { extractTextForTTS } from '../../services/tts/text-extractor.js'; |
||||
|
||||
interface Props { |
||||
open?: boolean; |
||||
event?: NostrEvent; |
||||
contentElement?: HTMLElement; |
||||
} |
||||
|
||||
let { open = $bindable(false), event, contentElement }: Props = $props(); |
||||
|
||||
function close() { |
||||
open = false; |
||||
} |
||||
</script> |
||||
|
||||
{#if open && (event || contentElement)} |
||||
<div |
||||
class="modal-overlay" |
||||
onclick={(e) => e.target === e.currentTarget && close()} |
||||
onkeydown={(e) => e.key === 'Escape' && close()} |
||||
role="dialog" |
||||
aria-modal="true" |
||||
tabindex="-1" |
||||
> |
||||
<div class="modal-content" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.key === 'Escape' && close()}> |
||||
<div class="modal-header"> |
||||
<h2>Read Aloud</h2> |
||||
<button onclick={close} class="close-button" aria-label="Close">×</button> |
||||
</div> |
||||
|
||||
<div class="modal-body"> |
||||
{#if contentElement} |
||||
<TTSControls text={contentElement} /> |
||||
{:else if event} |
||||
<TTSControls text={event.content} /> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.modal-overlay { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(15, 23, 42, 0.4); |
||||
backdrop-filter: blur(4px); |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
z-index: 1000; |
||||
} |
||||
|
||||
.modal-content { |
||||
background: var(--fog-post, #ffffff); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 8px; |
||||
max-width: 600px; |
||||
width: 90%; |
||||
max-height: 80vh; |
||||
overflow: auto; |
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
:global(.dark) .modal-content { |
||||
background: var(--fog-dark-post, #334155); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); |
||||
} |
||||
|
||||
.modal-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: 1rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .modal-header { |
||||
border-bottom-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.modal-header h2 { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .modal-header h2 { |
||||
color: var(--fog-dark-text, #cbd5e1); |
||||
} |
||||
|
||||
.close-button { |
||||
background: none; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
cursor: pointer; |
||||
color: var(--fog-text-light, #52667a); |
||||
padding: 0; |
||||
width: 2rem; |
||||
height: 2rem; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
border-radius: 0.25rem; |
||||
transition: background-color 0.2s; |
||||
} |
||||
|
||||
.close-button:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .close-button { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
:global(.dark) .close-button:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.modal-body { |
||||
padding: 1rem; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,245 @@
@@ -0,0 +1,245 @@
|
||||
/** |
||||
* Secure encrypted storage for API keys (TTS, GitHub, etc.) |
||||
* Uses password-based encryption similar to NIP-49 but for arbitrary strings |
||||
* Stores encrypted keys in IndexedDB preferences store |
||||
*/ |
||||
|
||||
import { getDB } from '../cache/indexeddb-store.js'; |
||||
import { savePreference, loadPreference } from '../preferences.js'; |
||||
|
||||
/** |
||||
* API key types |
||||
*/ |
||||
export type ApiKeyType =
|
||||
| 'tts.openai' |
||||
| 'tts.elevenlabs' |
||||
| 'github.token' |
||||
| string; // Allow custom types
|
||||
|
||||
/** |
||||
* Derive encryption key from password using PBKDF2 |
||||
*/ |
||||
async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> { |
||||
const encoder = new TextEncoder(); |
||||
const passwordKey = await crypto.subtle.importKey( |
||||
'raw', |
||||
encoder.encode(password), |
||||
'PBKDF2', |
||||
false, |
||||
['deriveBits', 'deriveKey'] |
||||
); |
||||
|
||||
// Create a new ArrayBuffer-backed Uint8Array to ensure proper type
|
||||
const saltArray = new Uint8Array(salt.length); |
||||
saltArray.set(salt); |
||||
|
||||
return crypto.subtle.deriveKey( |
||||
{ |
||||
name: 'PBKDF2', |
||||
salt: saltArray, |
||||
iterations: 100000, |
||||
hash: 'SHA-256' |
||||
}, |
||||
passwordKey, |
||||
{ |
||||
name: 'AES-GCM', |
||||
length: 256 |
||||
}, |
||||
false, |
||||
['encrypt', 'decrypt'] |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Encrypt an API key string |
||||
*/ |
||||
async function encryptApiKey(apiKey: string, password: string): Promise<string> { |
||||
const encoder = new TextEncoder(); |
||||
const data = encoder.encode(apiKey); |
||||
|
||||
// Generate random salt and IV
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16)); |
||||
const iv = crypto.getRandomValues(new Uint8Array(12)); |
||||
|
||||
// Derive key from password
|
||||
const key = await deriveKey(password, salt); |
||||
|
||||
// Encrypt
|
||||
const encrypted = await crypto.subtle.encrypt( |
||||
{ |
||||
name: 'AES-GCM', |
||||
iv: iv |
||||
}, |
||||
key, |
||||
data |
||||
); |
||||
|
||||
// Combine salt, IV, and encrypted data
|
||||
const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength); |
||||
combined.set(salt, 0); |
||||
combined.set(iv, salt.length); |
||||
combined.set(new Uint8Array(encrypted), salt.length + iv.length); |
||||
|
||||
// Convert to base64 for storage
|
||||
return btoa(String.fromCharCode(...combined)); |
||||
} |
||||
|
||||
/** |
||||
* Decrypt an API key string |
||||
*/ |
||||
async function decryptApiKey(encryptedData: string, password: string): Promise<string> { |
||||
try { |
||||
// Decode from base64
|
||||
const combined = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0)); |
||||
|
||||
// Extract salt, IV, and encrypted data
|
||||
const salt = combined.slice(0, 16); |
||||
const iv = combined.slice(16, 28); |
||||
const encrypted = combined.slice(28); |
||||
|
||||
// Derive key from password
|
||||
const key = await deriveKey(password, salt); |
||||
|
||||
// Decrypt
|
||||
const decrypted = await crypto.subtle.decrypt( |
||||
{ |
||||
name: 'AES-GCM', |
||||
iv: iv |
||||
}, |
||||
key, |
||||
encrypted |
||||
); |
||||
|
||||
// Convert back to string
|
||||
const decoder = new TextDecoder(); |
||||
return decoder.decode(decrypted); |
||||
} catch (error) { |
||||
throw new Error('Failed to decrypt API key: Invalid password or corrupted data'); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get password from user (prompts if not in session) |
||||
*/ |
||||
async function getPassword(): Promise<string> { |
||||
// Check if password is available in session
|
||||
// For now, we'll prompt - in the future this could check session manager
|
||||
return new Promise((resolve, reject) => { |
||||
const password = prompt('Enter your password to access API keys:'); |
||||
if (!password) { |
||||
reject(new Error('Password required')); |
||||
return; |
||||
} |
||||
resolve(password); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Save an encrypted API key |
||||
* @param type - API key type (e.g., 'tts.openai', 'github.token') |
||||
* @param apiKey - The API key to encrypt and store |
||||
* @param password - User password for encryption |
||||
*/ |
||||
export async function saveEncryptedApiKey( |
||||
type: ApiKeyType, |
||||
apiKey: string, |
||||
password: string |
||||
): Promise<void> { |
||||
if (!apiKey || !password) { |
||||
throw new Error('API key and password are required'); |
||||
} |
||||
|
||||
try { |
||||
const encrypted = await encryptApiKey(apiKey, password); |
||||
const preferenceKey = `api_key.${type}`; |
||||
await savePreference(preferenceKey, encrypted); |
||||
} catch (error) { |
||||
console.error('Failed to save encrypted API key:', error); |
||||
throw error; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Load and decrypt an API key |
||||
* @param type - API key type (e.g., 'tts.openai', 'github.token') |
||||
* @param password - User password for decryption (will prompt if not provided) |
||||
* @returns Decrypted API key or null if not found |
||||
*/ |
||||
export async function loadEncryptedApiKey( |
||||
type: ApiKeyType, |
||||
password?: string |
||||
): Promise<string | null> { |
||||
try { |
||||
const preferenceKey = `api_key.${type}`; |
||||
const encrypted = await loadPreference<string | null>(preferenceKey, null as string | null); |
||||
|
||||
if (!encrypted) { |
||||
return null; |
||||
} |
||||
|
||||
// Get password if not provided
|
||||
const pwd = password || await getPassword(); |
||||
|
||||
try { |
||||
return await decryptApiKey(encrypted, pwd); |
||||
} catch (error) { |
||||
console.error('Failed to decrypt API key:', error); |
||||
throw error; |
||||
} |
||||
} catch (error) { |
||||
console.error('Failed to load encrypted API key:', error); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Delete an API key |
||||
* @param type - API key type to delete |
||||
*/ |
||||
export async function deleteApiKey(type: ApiKeyType): Promise<void> { |
||||
try { |
||||
const preferenceKey = `api_key.${type}`; |
||||
const db = await getDB(); |
||||
await db.delete('preferences', preferenceKey); |
||||
} catch (error) { |
||||
console.error('Failed to delete API key:', error); |
||||
throw error; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Check if an API key exists (without decrypting) |
||||
* @param type - API key type to check |
||||
*/ |
||||
export async function hasApiKey(type: ApiKeyType): Promise<boolean> { |
||||
try { |
||||
const preferenceKey = `api_key.${type}`; |
||||
const encrypted = await loadPreference<string | null>(preferenceKey, null as string | null); |
||||
return encrypted !== null; |
||||
} catch (error) { |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* List all stored API key types |
||||
*/ |
||||
export async function listApiKeyTypes(): Promise<ApiKeyType[]> { |
||||
try { |
||||
const db = await getDB(); |
||||
const allPreferences = await db.getAll('preferences'); |
||||
const apiKeyTypes: ApiKeyType[] = []; |
||||
|
||||
for (const pref of allPreferences) { |
||||
if (pref.key.startsWith('api_key.')) { |
||||
const type = pref.key.replace('api_key.', '') as ApiKeyType; |
||||
apiKeyTypes.push(type); |
||||
} |
||||
} |
||||
|
||||
return apiKeyTypes; |
||||
} catch (error) { |
||||
console.error('Failed to list API key types:', error); |
||||
return []; |
||||
} |
||||
} |
||||
@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
|
||||
/** |
||||
* Text extraction utility for TTS |
||||
* Extracts plain text from HTML and filters out nostr: addresses |
||||
*/ |
||||
|
||||
import { findNIP21Links } from '../nostr/nip21-parser.js'; |
||||
|
||||
/** |
||||
* Extract plain text from HTML element or string |
||||
*/ |
||||
export function extractTextFromHTML(html: string | HTMLElement): string { |
||||
let text: string; |
||||
|
||||
if (typeof html === 'string') { |
||||
// Create temporary element to parse HTML
|
||||
const temp = document.createElement('div'); |
||||
temp.innerHTML = html; |
||||
text = temp.textContent || temp.innerText || ''; |
||||
} else { |
||||
text = html.textContent || html.innerText || ''; |
||||
} |
||||
|
||||
return text; |
||||
} |
||||
|
||||
/** |
||||
* Remove nostr: addresses from text |
||||
* Preserves sentence structure by replacing with empty string or space |
||||
*/ |
||||
export function filterNostrAddresses(text: string): string { |
||||
// Find all nostr: links
|
||||
const links = findNIP21Links(text); |
||||
|
||||
if (links.length === 0) { |
||||
return text; |
||||
} |
||||
|
||||
// Replace from end to start to preserve indices
|
||||
let filtered = text; |
||||
for (let i = links.length - 1; i >= 0; i--) { |
||||
const link = links[i]; |
||||
const before = filtered.substring(0, link.start); |
||||
const after = filtered.substring(link.end); |
||||
|
||||
// Check if there's whitespace before and after - if so, remove the whole thing
|
||||
// Otherwise, just remove the nostr: part and keep the identifier
|
||||
const charBefore = before[before.length - 1] || ' '; |
||||
const charAfter = after[0] || ' '; |
||||
const isWhitespaceBefore = /\s/.test(charBefore); |
||||
const isWhitespaceAfter = /\s/.test(charAfter); |
||||
|
||||
if (isWhitespaceBefore && isWhitespaceAfter) { |
||||
// Remove entire address including surrounding whitespace (but keep one space)
|
||||
filtered = before.trimEnd() + ' ' + after.trimStart(); |
||||
} else if (isWhitespaceBefore) { |
||||
// Remove address but keep space before
|
||||
filtered = before + after; |
||||
} else if (isWhitespaceAfter) { |
||||
// Remove address but keep space after
|
||||
filtered = before + after; |
||||
} else { |
||||
// No whitespace - just remove the address (might break words, but better than reading it)
|
||||
filtered = before + after; |
||||
} |
||||
} |
||||
|
||||
// Clean up multiple spaces
|
||||
filtered = filtered.replace(/\s+/g, ' '); |
||||
|
||||
return filtered.trim(); |
||||
} |
||||
|
||||
/** |
||||
* Extract and filter text for TTS |
||||
* Combines extraction and filtering in one step |
||||
*/ |
||||
export function extractTextForTTS(html: string | HTMLElement): string { |
||||
const text = extractTextFromHTML(html); |
||||
return filterNostrAddresses(text); |
||||
} |
||||
|
||||
/** |
||||
* Split text into sentences for better TTS processing |
||||
*/ |
||||
export function splitIntoSentences(text: string): string[] { |
||||
// Simple sentence splitting - can be improved
|
||||
const sentences = text |
||||
.split(/([.!?]+\s+)/) |
||||
.filter(s => s.trim().length > 0) |
||||
.map(s => s.trim()); |
||||
|
||||
return sentences; |
||||
} |
||||
|
||||
/** |
||||
* Normalize text for TTS (remove extra whitespace, normalize punctuation) |
||||
*/ |
||||
export function normalizeTextForTTS(text: string): string { |
||||
return text |
||||
.replace(/\s+/g, ' ') // Multiple spaces to single space
|
||||
.replace(/\n+/g, ' ') // Newlines to space
|
||||
.replace(/\t+/g, ' ') // Tabs to space
|
||||
.trim(); |
||||
} |
||||
@ -0,0 +1,339 @@
@@ -0,0 +1,339 @@
|
||||
/** |
||||
* TTS Service |
||||
* Manages text-to-speech with multiple provider support |
||||
*/ |
||||
|
||||
import type { TTSProvider, TTSProviderInterface, TTSOptions, TTSState, TTSVoice, TTSEventCallbacks } from './types.js'; |
||||
|
||||
/** |
||||
* Web Speech API TTS Provider |
||||
*/ |
||||
class WebSpeechProvider implements TTSProviderInterface { |
||||
readonly name = 'Web Speech API'; |
||||
readonly type: TTSProvider = 'webspeech'; |
||||
|
||||
private synth: SpeechSynthesis; |
||||
private utterance: SpeechSynthesisUtterance | null = null; |
||||
private state: TTSState = 'idle'; |
||||
private callbacks: TTSEventCallbacks = {}; |
||||
private currentText = ''; |
||||
private currentOptions: TTSOptions = {}; |
||||
private voices: TTSVoice[] = []; |
||||
private voiceLoaded = false; |
||||
|
||||
constructor() { |
||||
if (typeof window === 'undefined' || !('speechSynthesis' in window)) { |
||||
throw new Error('Web Speech API not available'); |
||||
} |
||||
this.synth = window.speechSynthesis; |
||||
this.loadVoices(); |
||||
|
||||
// Reload voices when they become available (some browsers load them asynchronously)
|
||||
this.synth.onvoiceschanged = () => { |
||||
this.loadVoices(); |
||||
}; |
||||
} |
||||
|
||||
private loadVoices(): void { |
||||
const browserVoices = this.synth.getVoices(); |
||||
this.voices = browserVoices.map(voice => ({ |
||||
id: voice.voiceURI, |
||||
name: voice.name, |
||||
lang: voice.lang, |
||||
gender: voice.name.toLowerCase().includes('female') ? 'female' : |
||||
voice.name.toLowerCase().includes('male') ? 'male' : 'neutral', |
||||
provider: 'webspeech' |
||||
})); |
||||
this.voiceLoaded = true; |
||||
} |
||||
|
||||
async isAvailable(): Promise<boolean> { |
||||
return typeof window !== 'undefined' && 'speechSynthesis' in window; |
||||
} |
||||
|
||||
async getVoices(): Promise<TTSVoice[]> { |
||||
if (!this.voiceLoaded) { |
||||
await new Promise(resolve => setTimeout(resolve, 100)); |
||||
this.loadVoices(); |
||||
} |
||||
return this.voices; |
||||
} |
||||
|
||||
async speak(text: string, options?: TTSOptions): Promise<void> { |
||||
// Stop any current speech
|
||||
this.stop(); |
||||
|
||||
if (!text.trim()) { |
||||
return; |
||||
} |
||||
|
||||
this.currentText = text; |
||||
this.currentOptions = options || {}; |
||||
|
||||
// Create utterance
|
||||
this.utterance = new SpeechSynthesisUtterance(text); |
||||
|
||||
// Set voice
|
||||
if (options?.voice && options.voice.provider === 'webspeech') { |
||||
const browserVoice = this.synth.getVoices().find(v => v.voiceURI === options.voice!.id); |
||||
if (browserVoice) { |
||||
this.utterance.voice = browserVoice; |
||||
} |
||||
} |
||||
|
||||
// Set options
|
||||
this.utterance.rate = options?.speed ?? 1.0; |
||||
this.utterance.pitch = options?.pitch ?? 1.0; |
||||
this.utterance.volume = options?.volume ?? 1.0; |
||||
|
||||
// Set up event handlers
|
||||
this.utterance.onstart = () => { |
||||
this.state = 'playing'; |
||||
this.callbacks.onStateChange?.(this.state); |
||||
}; |
||||
|
||||
this.utterance.onend = () => { |
||||
this.state = 'idle'; |
||||
this.utterance = null; |
||||
this.callbacks.onStateChange?.(this.state); |
||||
this.callbacks.onEnd?.(); |
||||
}; |
||||
|
||||
this.utterance.onerror = (event) => { |
||||
this.state = 'error'; |
||||
const error = new Error(`Speech synthesis error: ${event.error}`); |
||||
this.callbacks.onError?.(error); |
||||
this.callbacks.onStateChange?.(this.state); |
||||
}; |
||||
|
||||
// Speak
|
||||
this.synth.speak(this.utterance); |
||||
this.state = 'playing'; |
||||
this.callbacks.onStateChange?.(this.state); |
||||
} |
||||
|
||||
async pause(): Promise<void> { |
||||
if (this.state === 'playing' && this.synth.speaking) { |
||||
this.synth.pause(); |
||||
this.state = 'paused'; |
||||
this.callbacks.onStateChange?.(this.state); |
||||
} |
||||
} |
||||
|
||||
async resume(): Promise<void> { |
||||
if (this.state === 'paused' && this.synth.paused) { |
||||
this.synth.resume(); |
||||
this.state = 'playing'; |
||||
this.callbacks.onStateChange?.(this.state); |
||||
} |
||||
} |
||||
|
||||
async stop(): Promise<void> { |
||||
if (this.synth.speaking || this.synth.paused) { |
||||
this.synth.cancel(); |
||||
} |
||||
this.utterance = null; |
||||
this.state = 'idle'; |
||||
this.callbacks.onStateChange?.(this.state); |
||||
} |
||||
|
||||
getState(): TTSState { |
||||
return this.state; |
||||
} |
||||
|
||||
async getProgress(): Promise<number> { |
||||
// Web Speech API doesn't provide progress, so we estimate based on time
|
||||
// This is a simplified implementation
|
||||
return 0; |
||||
} |
||||
|
||||
async setProgress(position: number): Promise<void> { |
||||
// Web Speech API doesn't support seeking
|
||||
// Would need to stop and restart at new position
|
||||
if (this.currentText && position >= 0 && position <= 1) { |
||||
const charIndex = Math.floor(this.currentText.length * position); |
||||
const newText = this.currentText.substring(charIndex); |
||||
await this.stop(); |
||||
await this.speak(newText, this.currentOptions); |
||||
} |
||||
} |
||||
|
||||
destroy(): void { |
||||
this.stop(); |
||||
this.callbacks = {}; |
||||
} |
||||
|
||||
setCallbacks(callbacks: TTSEventCallbacks): void { |
||||
this.callbacks = { ...this.callbacks, ...callbacks }; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* TTS Service |
||||
* Manages TTS providers and provides unified interface |
||||
*/ |
||||
export class TTSService { |
||||
private provider: TTSProviderInterface | null = null; |
||||
private providerType: TTSProvider = 'webspeech'; |
||||
private callbacks: TTSEventCallbacks = {}; |
||||
|
||||
/** |
||||
* Initialize TTS service with a provider |
||||
*/ |
||||
async initialize(providerType: TTSProvider = 'webspeech'): Promise<void> { |
||||
// Cleanup existing provider
|
||||
if (this.provider) { |
||||
this.provider.destroy(); |
||||
} |
||||
|
||||
this.providerType = providerType; |
||||
|
||||
// Create provider
|
||||
if (providerType === 'webspeech') { |
||||
try { |
||||
this.provider = new WebSpeechProvider(); |
||||
const available = await this.provider.isAvailable(); |
||||
if (!available) { |
||||
throw new Error('Web Speech API not available'); |
||||
} |
||||
if (this.provider.setCallbacks) { |
||||
this.provider.setCallbacks(this.callbacks); |
||||
} |
||||
} catch (error) { |
||||
console.error('Failed to initialize Web Speech API:', error); |
||||
throw error; |
||||
} |
||||
} else if (providerType === 'openai') { |
||||
// TODO: Implement OpenAI provider
|
||||
throw new Error('OpenAI TTS provider not yet implemented'); |
||||
} else if (providerType === 'elevenlabs') { |
||||
// TODO: Implement ElevenLabs provider
|
||||
throw new Error('ElevenLabs TTS provider not yet implemented'); |
||||
} else { |
||||
throw new Error(`Unknown TTS provider: ${providerType}`); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Check if current provider is available |
||||
*/ |
||||
async isAvailable(): Promise<boolean> { |
||||
if (!this.provider) { |
||||
await this.initialize(); |
||||
} |
||||
return this.provider ? await this.provider.isAvailable() : false; |
||||
} |
||||
|
||||
/** |
||||
* Get available voices |
||||
*/ |
||||
async getVoices(): Promise<TTSVoice[]> { |
||||
if (!this.provider) { |
||||
await this.initialize(); |
||||
} |
||||
return this.provider ? await this.provider.getVoices() : []; |
||||
} |
||||
|
||||
/** |
||||
* Speak text |
||||
*/ |
||||
async speak(text: string, options?: TTSOptions): Promise<void> { |
||||
if (!this.provider) { |
||||
await this.initialize(); |
||||
} |
||||
if (this.provider) { |
||||
await this.provider.speak(text, options); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Pause playback |
||||
*/ |
||||
async pause(): Promise<void> { |
||||
if (this.provider) { |
||||
await this.provider.pause(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Resume playback |
||||
*/ |
||||
async resume(): Promise<void> { |
||||
if (this.provider) { |
||||
await this.provider.resume(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Stop playback |
||||
*/ |
||||
async stop(): Promise<void> { |
||||
if (this.provider) { |
||||
await this.provider.stop(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get current state |
||||
*/ |
||||
getState(): TTSState { |
||||
return this.provider ? this.provider.getState() : 'idle'; |
||||
} |
||||
|
||||
/** |
||||
* Get current progress |
||||
*/ |
||||
async getProgress(): Promise<number> { |
||||
return this.provider ? await this.provider.getProgress() : 0; |
||||
} |
||||
|
||||
/** |
||||
* Set playback position |
||||
*/ |
||||
async setProgress(position: number): Promise<void> { |
||||
if (this.provider) { |
||||
await this.provider.setProgress(position); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Set event callbacks |
||||
*/ |
||||
setCallbacks(callbacks: TTSEventCallbacks): void { |
||||
this.callbacks = { ...this.callbacks, ...callbacks }; |
||||
if (this.provider && this.provider.setCallbacks) { |
||||
this.provider.setCallbacks(this.callbacks); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get current provider type |
||||
*/ |
||||
getProviderType(): TTSProvider { |
||||
return this.providerType; |
||||
} |
||||
|
||||
/** |
||||
* Cleanup |
||||
*/ |
||||
destroy(): void { |
||||
if (this.provider) { |
||||
this.provider.destroy(); |
||||
this.provider = null; |
||||
} |
||||
this.callbacks = {}; |
||||
} |
||||
} |
||||
|
||||
// Singleton instance
|
||||
let ttsServiceInstance: TTSService | null = null; |
||||
|
||||
/** |
||||
* Get TTS service instance |
||||
*/ |
||||
export function getTTSService(): TTSService { |
||||
if (!ttsServiceInstance) { |
||||
ttsServiceInstance = new TTSService(); |
||||
} |
||||
return ttsServiceInstance; |
||||
} |
||||
@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
|
||||
/** |
||||
* TTS (Text-to-Speech) types and interfaces |
||||
*/ |
||||
|
||||
/** |
||||
* TTS provider type |
||||
*/ |
||||
export type TTSProvider = 'webspeech' | 'openai' | 'elevenlabs'; |
||||
|
||||
/** |
||||
* TTS voice configuration |
||||
*/ |
||||
export interface TTSVoice { |
||||
id: string; |
||||
name: string; |
||||
lang: string; |
||||
gender?: 'male' | 'female' | 'neutral'; |
||||
provider: TTSProvider; |
||||
} |
||||
|
||||
/** |
||||
* TTS playback state |
||||
*/ |
||||
export type TTSState = 'idle' | 'playing' | 'paused' | 'error'; |
||||
|
||||
/** |
||||
* TTS options |
||||
*/ |
||||
export interface TTSOptions { |
||||
voice?: TTSVoice; |
||||
speed?: number; // 0.5 to 2.0
|
||||
pitch?: number; // 0.5 to 2.0
|
||||
volume?: number; // 0.0 to 1.0
|
||||
provider?: TTSProvider; |
||||
} |
||||
|
||||
/** |
||||
* TTS provider interface |
||||
*/ |
||||
export interface TTSProviderInterface { |
||||
/** |
||||
* Provider name |
||||
*/ |
||||
readonly name: string; |
||||
|
||||
/** |
||||
* Provider type |
||||
*/ |
||||
readonly type: TTSProvider; |
||||
|
||||
/** |
||||
* Check if provider is available |
||||
*/ |
||||
isAvailable(): Promise<boolean>; |
||||
|
||||
/** |
||||
* Get available voices |
||||
*/ |
||||
getVoices(): Promise<TTSVoice[]>; |
||||
|
||||
/** |
||||
* Speak text |
||||
*/ |
||||
speak(text: string, options?: TTSOptions): Promise<void>; |
||||
|
||||
/** |
||||
* Pause playback |
||||
*/ |
||||
pause(): Promise<void>; |
||||
|
||||
/** |
||||
* Resume playback |
||||
*/ |
||||
resume(): Promise<void>; |
||||
|
||||
/** |
||||
* Stop playback |
||||
*/ |
||||
stop(): Promise<void>; |
||||
|
||||
/** |
||||
* Get current state |
||||
*/ |
||||
getState(): TTSState; |
||||
|
||||
/** |
||||
* Get current playback position (0-1) |
||||
*/ |
||||
getProgress(): Promise<number>; |
||||
|
||||
/** |
||||
* Set playback position (0-1) |
||||
*/ |
||||
setProgress(position: number): Promise<void>; |
||||
|
||||
/** |
||||
* Cleanup resources |
||||
*/ |
||||
destroy(): void; |
||||
|
||||
/** |
||||
* Set event callbacks (optional - not all providers may support this) |
||||
*/ |
||||
setCallbacks?(callbacks: TTSEventCallbacks): void; |
||||
} |
||||
|
||||
/** |
||||
* TTS event callbacks |
||||
*/ |
||||
export interface TTSEventCallbacks { |
||||
onStateChange?: (state: TTSState) => void; |
||||
onProgress?: (progress: number) => void; |
||||
onError?: (error: Error) => void; |
||||
onEnd?: () => void; |
||||
} |
||||
Loading…
Reference in new issue