11 changed files with 1710 additions and 16 deletions
@ -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 @@ |
|||||||
|
<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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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