Initial commit: Speechify clone with multi-provider TTS

This commit is contained in:
ifedan-ed 2026-04-11 02:27:05 +02:00
commit 6458b101e1
6 changed files with 1107 additions and 0 deletions

Binary file not shown.

248
main.py Normal file
View file

@ -0,0 +1,248 @@
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel
import httpx
import re
import os
app = FastAPI(title="Speechify Clone")
LITELLM_BASE = "http://localhost:4000"
LITELLM_KEY = "sk-c5c58adcc8dda288067f034ee927be23509142d274fe48418c25009dfb507a52"
TTS_MODELS = {
"openai-tts-1": {
"name": "OpenAI TTS",
"quality": "Standard",
"voices": [
{"id": "alloy", "name": "Alloy"},
{"id": "echo", "name": "Echo"},
{"id": "fable", "name": "Fable"},
{"id": "onyx", "name": "Onyx"},
{"id": "nova", "name": "Nova"},
{"id": "shimmer", "name": "Shimmer"},
]
},
"openai-tts-1-hd": {
"name": "OpenAI TTS HD",
"quality": "High Definition",
"voices": [
{"id": "alloy", "name": "Alloy"},
{"id": "echo", "name": "Echo"},
{"id": "fable", "name": "Fable"},
{"id": "onyx", "name": "Onyx"},
{"id": "nova", "name": "Nova"},
{"id": "shimmer", "name": "Shimmer"},
]
},
"elevenlabs-v3": {
"name": "ElevenLabs v3",
"quality": "Premium",
"voices": [
{"id": "Rachel", "name": "Rachel"},
{"id": "Drew", "name": "Drew"},
{"id": "Clyde", "name": "Clyde"},
{"id": "Paul", "name": "Paul"},
{"id": "Domi", "name": "Domi"},
{"id": "Dave", "name": "Dave"},
{"id": "Fin", "name": "Fin"},
{"id": "Bella", "name": "Bella"},
{"id": "Antoni", "name": "Antoni"},
{"id": "Thomas", "name": "Thomas"},
{"id": "Charlie", "name": "Charlie"},
{"id": "Emily", "name": "Emily"},
{"id": "Elli", "name": "Elli"},
{"id": "Callum", "name": "Callum"},
{"id": "Patrick", "name": "Patrick"},
{"id": "Harry", "name": "Harry"},
{"id": "Liam", "name": "Liam"},
{"id": "Dorothy", "name": "Dorothy"},
{"id": "Josh", "name": "Josh"},
{"id": "Arnold", "name": "Arnold"},
{"id": "Adam", "name": "Adam"},
{"id": "Sam", "name": "Sam"},
]
},
"elevenlabs-multilingual": {
"name": "ElevenLabs Multilingual",
"quality": "Premium Multilingual",
"voices": [
{"id": "Rachel", "name": "Rachel"},
{"id": "Drew", "name": "Drew"},
{"id": "Clyde", "name": "Clyde"},
{"id": "Paul", "name": "Paul"},
{"id": "Domi", "name": "Domi"},
{"id": "Dave", "name": "Dave"},
{"id": "Fin", "name": "Fin"},
{"id": "Bella", "name": "Bella"},
{"id": "Antoni", "name": "Antoni"},
{"id": "Thomas", "name": "Thomas"},
{"id": "Charlie", "name": "Charlie"},
{"id": "Emily", "name": "Emily"},
{"id": "Elli", "name": "Elli"},
{"id": "Callum", "name": "Callum"},
{"id": "Patrick", "name": "Patrick"},
{"id": "Harry", "name": "Harry"},
{"id": "Liam", "name": "Liam"},
{"id": "Dorothy", "name": "Dorothy"},
{"id": "Josh", "name": "Josh"},
{"id": "Arnold", "name": "Arnold"},
{"id": "Adam", "name": "Adam"},
{"id": "Sam", "name": "Sam"},
]
},
"vertex-gemini-2.5-flash-tts": {
"name": "Gemini 2.5 Flash TTS",
"quality": "Fast",
"voices": [
{"id": "Aoede", "name": "Aoede"},
{"id": "Charon", "name": "Charon"},
{"id": "Fenrir", "name": "Fenrir"},
{"id": "Kore", "name": "Kore"},
{"id": "Puck", "name": "Puck"},
{"id": "Orbit", "name": "Orbit"},
{"id": "Zephyr", "name": "Zephyr"},
{"id": "Autonoe", "name": "Autonoe"},
{"id": "Enceladus", "name": "Enceladus"},
{"id": "Laomedeia", "name": "Laomedeia"},
{"id": "Achernar", "name": "Achernar"},
{"id": "Rasalgethi", "name": "Rasalgethi"},
{"id": "Schedar", "name": "Schedar"},
{"id": "Sulafat", "name": "Sulafat"},
]
},
"vertex-gemini-2.5-pro-tts": {
"name": "Gemini 2.5 Pro TTS",
"quality": "High Quality",
"voices": [
{"id": "Aoede", "name": "Aoede"},
{"id": "Charon", "name": "Charon"},
{"id": "Fenrir", "name": "Fenrir"},
{"id": "Kore", "name": "Kore"},
{"id": "Puck", "name": "Puck"},
{"id": "Orbit", "name": "Orbit"},
{"id": "Zephyr", "name": "Zephyr"},
{"id": "Autonoe", "name": "Autonoe"},
{"id": "Enceladus", "name": "Enceladus"},
{"id": "Laomedeia", "name": "Laomedeia"},
{"id": "Achernar", "name": "Achernar"},
{"id": "Rasalgethi", "name": "Rasalgethi"},
{"id": "Schedar", "name": "Schedar"},
{"id": "Sulafat", "name": "Sulafat"},
]
},
}
class TTSRequest(BaseModel):
text: str
model: str
voice: str
speed: float = 1.0
class URLRequest(BaseModel):
url: str
@app.get("/api/voices")
async def get_voices():
return TTS_MODELS
@app.post("/api/tts")
async def text_to_speech(req: TTSRequest):
payload = {
"model": req.model,
"input": req.text,
"voice": req.voice,
}
if req.speed != 1.0:
payload["speed"] = req.speed
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
f"{LITELLM_BASE}/v1/audio/speech",
json=payload,
headers={"Authorization": f"Bearer {LITELLM_KEY}"},
)
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail=resp.text)
return Response(
content=resp.content,
media_type="audio/mpeg",
)
@app.post("/api/extract-url")
async def extract_from_url(req: URLRequest):
try:
from bs4 import BeautifulSoup
import html2text
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
resp = await client.get(
req.url,
headers={"User-Agent": "Mozilla/5.0 (compatible; SpeechifyClone/1.0)"},
)
soup = BeautifulSoup(resp.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header", "aside", "iframe"]):
tag.decompose()
main = (
soup.find("article")
or soup.find("main")
or soup.find(id="content")
or soup.find(class_="content")
or soup.body
)
h = html2text.HTML2Text()
h.ignore_links = True
h.ignore_images = True
h.body_width = 0
text = h.handle(str(main))
text = re.sub(r'\n{3,}', '\n\n', text).strip()
title = soup.title.string.strip() if soup.title else req.url
return {"text": text, "title": title}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/extract-pdf")
async def extract_from_pdf(file: UploadFile = File(...)):
try:
import fitz # PyMuPDF
content = await file.read()
doc = fitz.open(stream=content, filetype="pdf")
pages = []
for page in doc:
pages.append(page.get_text())
text = "\n\n".join(pages)
text = re.sub(r'\n{3,}', '\n\n', text).strip()
return {"text": text, "title": file.filename}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
async def root():
return FileResponse("static/index.html")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080, reload=True)

8
requirements.txt Normal file
View file

@ -0,0 +1,8 @@
fastapi
uvicorn
httpx
python-multipart
pymupdf
beautifulsoup4
html2text
requests

419
static/app.js Normal file
View file

@ -0,0 +1,419 @@
// ── STATE ──────────────────────────────────────────────────────────
const state = {
sentences: [], // [{text, isPara}]
currentIdx: 0,
isPlaying: false,
speed: 1.0,
model: 'openai-tts-1',
voice: 'nova',
fontSize: 18,
};
let currentAudio = null;
const audioCache = new Map(); // idx → blob URL
let allModels = {};
// ── DOM ────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const textViewer = $('text-viewer');
const modelSelect = $('model-select');
const voiceSelect = $('voice-select');
const speedSlider = $('speed-slider');
const speedLabel = $('speed-label');
const btnPlay = $('btn-play');
const btnPrev = $('btn-prev');
const btnNext = $('btn-next');
const nowPlaying = $('now-playing');
const curSent = $('cur-sent');
const totSent = $('tot-sent');
const progressFill = $('progress-fill');
const loadingOv = $('loading-overlay');
const loadingText = $('loading-text');
// ── LOADING ────────────────────────────────────────────────────────
const showLoading = (msg = 'Loading…') => { loadingText.textContent = msg; loadingOv.classList.add('show'); };
const hideLoading = () => loadingOv.classList.remove('show');
// ── VOICES INIT ────────────────────────────────────────────────────
async function initVoices() {
const resp = await fetch('/api/voices');
allModels = await resp.json();
modelSelect.innerHTML = '';
for (const [id, info] of Object.entries(allModels)) {
const o = document.createElement('option');
o.value = id;
o.textContent = `${info.name}${info.quality}`;
modelSelect.appendChild(o);
}
modelSelect.value = 'openai-tts-1';
refreshVoices();
}
function refreshVoices() {
state.model = modelSelect.value;
const voices = allModels[state.model]?.voices || [];
voiceSelect.innerHTML = '';
for (const v of voices) {
const o = document.createElement('option');
o.value = v.id;
o.textContent = v.name;
voiceSelect.appendChild(o);
}
state.voice = voiceSelect.value;
audioCache.clear();
}
modelSelect.addEventListener('change', refreshVoices);
voiceSelect.addEventListener('change', () => { state.voice = voiceSelect.value; audioCache.clear(); });
// ── SENTENCE SPLITTING ─────────────────────────────────────────────
function splitSentences(text) {
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const paras = text.split(/\n{2,}/);
const result = [];
for (let pi = 0; pi < paras.length; pi++) {
const para = paras[pi].trim();
if (!para) continue;
// Split on sentence-ending punctuation followed by space + capital
const parts = para.split(/(?<=[.!?…])\s+(?=[A-Z"'"'(])/);
for (const part of parts) {
const t = part.trim();
if (t) result.push({ text: t, isPara: false });
}
if (pi < paras.length - 1) result.push({ text: '', isPara: true });
}
return result;
}
// ── RENDER TEXT ────────────────────────────────────────────────────
function renderText(sentences) {
textViewer.innerHTML = '';
sentences.forEach((s, i) => {
if (s.isPara) {
textViewer.appendChild(Object.assign(document.createElement('span'), { className: 'para-gap' }));
return;
}
const span = document.createElement('span');
span.className = 's';
span.dataset.i = i;
span.textContent = s.text + ' ';
span.addEventListener('click', () => jumpTo(i));
textViewer.appendChild(span);
});
}
// ── LOAD DOCUMENT ──────────────────────────────────────────────────
function loadDoc(text, title = 'Document') {
state.sentences = splitSentences(text);
state.currentIdx = 0;
audioCache.clear();
stopAudio();
const real = state.sentences.filter(s => !s.isPara).length;
$('document-header').style.display = '';
$('document-title').textContent = title;
$('document-meta').textContent = `${real} sentences`;
totSent.textContent = real;
curSent.textContent = 0;
progressFill.style.width = '0%';
nowPlaying.textContent = 'Ready — press play';
renderText(state.sentences);
textViewer.style.fontSize = `${state.fontSize}px`;
}
// ── AUDIO ──────────────────────────────────────────────────────────
async function generateAudio(idx) {
if (audioCache.has(idx)) return audioCache.get(idx);
const s = state.sentences[idx];
const resp = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: s.text, model: state.model, voice: state.voice, speed: state.speed }),
});
if (!resp.ok) throw new Error(await resp.text());
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
audioCache.set(idx, url);
return url;
}
function prefetch(fromIdx, count = 3) {
let n = 0, i = fromIdx + 1;
while (n < count && i < state.sentences.length) {
const s = state.sentences[i];
if (!s.isPara && !audioCache.has(i)) generateAudio(i).catch(() => {});
if (!s.isPara) n++;
i++;
}
}
function stopAudio() {
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
}
// ── HIGHLIGHT ──────────────────────────────────────────────────────
function highlight(idx) {
document.querySelectorAll('.s.active').forEach(el => el.classList.remove('active'));
const el = document.querySelector(`.s[data-i="${idx}"]`);
if (el) {
el.classList.add('active');
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
state.currentIdx = idx;
const s = state.sentences[idx];
if (s) nowPlaying.textContent = s.text;
// Update counter
const realIdx = state.sentences.slice(0, idx + 1).filter(s => !s.isPara).length;
const realTotal = state.sentences.filter(s => !s.isPara).length;
curSent.textContent = realIdx;
progressFill.style.width = realTotal ? `${(realIdx / realTotal) * 100}%` : '0%';
}
// ── NAVIGATION HELPERS ─────────────────────────────────────────────
function nextReal(from) {
for (let i = from; i < state.sentences.length; i++)
if (!state.sentences[i].isPara) return i;
return -1;
}
function prevReal(from) {
for (let i = from; i >= 0; i--)
if (!state.sentences[i].isPara) return i;
return -1;
}
// ── PLAYBACK ───────────────────────────────────────────────────────
async function playSentence(idx) {
if (idx < 0 || idx >= state.sentences.length) { stop(); return; }
const s = state.sentences[idx];
if (s.isPara) { playSentence(nextReal(idx + 1)); return; }
highlight(idx);
stopAudio();
try {
const url = await generateAudio(idx);
if (!state.isPlaying) return;
const audio = new Audio(url);
audio.playbackRate = state.speed;
currentAudio = audio;
prefetch(idx);
audio.play().catch(console.error);
audio.onended = () => {
if (state.isPlaying) playSentence(nextReal(idx + 1));
};
audio.onerror = () => {
if (state.isPlaying) playSentence(nextReal(idx + 1));
};
} catch (e) {
console.error('TTS error:', e);
if (state.isPlaying) playSentence(nextReal(idx + 1));
}
}
function stop() {
state.isPlaying = false;
stopAudio();
updatePlayBtn();
document.querySelectorAll('.s.active').forEach(el => el.classList.remove('active'));
nowPlaying.textContent = 'Finished';
}
function updatePlayBtn() {
btnPlay.querySelector('.icon-play').style.display = state.isPlaying ? 'none' : '';
btnPlay.querySelector('.icon-pause').style.display = state.isPlaying ? '' : 'none';
}
function jumpTo(idx) {
state.currentIdx = idx;
if (state.isPlaying) {
stopAudio();
playSentence(idx);
} else {
highlight(idx);
}
}
// ── PLAYER CONTROLS ────────────────────────────────────────────────
btnPlay.addEventListener('click', () => {
if (!state.sentences.length) return;
state.isPlaying = !state.isPlaying;
updatePlayBtn();
if (state.isPlaying) {
const idx = state.sentences[state.currentIdx]?.isPara
? nextReal(state.currentIdx)
: state.currentIdx;
playSentence(idx >= 0 ? idx : nextReal(0));
} else {
stopAudio();
nowPlaying.textContent = 'Paused';
}
});
btnNext.addEventListener('click', () => {
const n = nextReal(state.currentIdx + 1);
if (n >= 0) jumpTo(n);
});
btnPrev.addEventListener('click', () => {
const p = prevReal(state.currentIdx - 1);
if (p >= 0) jumpTo(p);
});
// Progress bar click
$('progress-track').addEventListener('click', e => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
const reals = state.sentences.map((s, i) => s.isPara ? null : i).filter(i => i !== null);
const target = reals[Math.floor(ratio * reals.length)];
if (target !== undefined) jumpTo(target);
});
// ── SPEED ──────────────────────────────────────────────────────────
function setSpeed(v) {
state.speed = v;
speedSlider.value = v;
speedLabel.textContent = `${v}×`;
if (currentAudio) currentAudio.playbackRate = v;
document.querySelectorAll('.speed-btn').forEach(b => {
b.classList.toggle('active', parseFloat(b.dataset.speed) === v);
});
}
speedSlider.addEventListener('input', () => setSpeed(parseFloat(speedSlider.value)));
document.querySelectorAll('.speed-btn').forEach(b => {
b.addEventListener('click', () => setSpeed(parseFloat(b.dataset.speed)));
});
// ── FONT SIZE ──────────────────────────────────────────────────────
$('font-increase').addEventListener('click', () => {
state.fontSize = Math.min(state.fontSize + 2, 34);
textViewer.style.fontSize = `${state.fontSize}px`;
$('font-size-label').textContent = `${state.fontSize}px`;
});
$('font-decrease').addEventListener('click', () => {
state.fontSize = Math.max(state.fontSize - 2, 12);
textViewer.style.fontSize = `${state.fontSize}px`;
$('font-size-label').textContent = `${state.fontSize}px`;
});
// ── SIDEBAR TOGGLES ────────────────────────────────────────────────
$('btn-sidebar-toggle').addEventListener('click', () => $('sidebar-left').classList.toggle('collapsed'));
$('btn-settings-toggle').addEventListener('click', () => $('sidebar-right').classList.toggle('collapsed'));
// ── TABS ───────────────────────────────────────────────────────────
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
$(`tab-${tab.dataset.tab}`).classList.add('active');
});
});
// ── TEXT INPUT ─────────────────────────────────────────────────────
$('btn-load-text').addEventListener('click', () => {
const text = $('text-input').value.trim();
if (text) loadDoc(text, 'Pasted Text');
});
// ── URL INPUT ──────────────────────────────────────────────────────
$('btn-load-url').addEventListener('click', async () => {
const url = $('url-input').value.trim();
if (!url) return;
const status = $('url-status');
showLoading('Fetching article…');
try {
const resp = await fetch('/api/extract-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!resp.ok) throw new Error(await resp.text());
const data = await resp.json();
loadDoc(data.text, data.title || url);
status.textContent = '✓ Loaded';
} catch (e) {
status.textContent = '✗ ' + e.message;
} finally {
hideLoading();
}
});
// ── PDF INPUT ──────────────────────────────────────────────────────
const uploadArea = $('upload-area');
const pdfInput = $('pdf-input');
uploadArea.addEventListener('click', () => pdfInput.click());
uploadArea.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('drag-over'); });
uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('drag-over'));
uploadArea.addEventListener('drop', e => {
e.preventDefault(); uploadArea.classList.remove('drag-over');
const f = e.dataTransfer.files[0];
if (f?.type === 'application/pdf') uploadPDF(f);
});
pdfInput.addEventListener('change', () => { if (pdfInput.files[0]) uploadPDF(pdfInput.files[0]); });
async function uploadPDF(file) {
const status = $('pdf-status');
showLoading('Extracting PDF…');
const form = new FormData();
form.append('file', file);
try {
const resp = await fetch('/api/extract-pdf', { method: 'POST', body: form });
if (!resp.ok) throw new Error(await resp.text());
const data = await resp.json();
loadDoc(data.text, data.title || file.name);
status.textContent = '✓ Loaded';
} catch (e) {
status.textContent = '✗ ' + e.message;
} finally {
hideLoading();
}
}
// ── VOICE PREVIEW ─────────────────────────────────────────────────
$('btn-preview').addEventListener('click', async () => {
showLoading('Generating preview…');
try {
const resp = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: 'Hello! This is a preview of the selected voice. How does it sound to you?',
model: state.model, voice: state.voice, speed: state.speed,
}),
});
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = new Audio(url);
a.playbackRate = state.speed;
a.play();
a.onended = () => URL.revokeObjectURL(url);
} catch (e) {
console.error('Preview failed:', e);
} finally {
hideLoading();
}
});
// ── KEYBOARD SHORTCUTS ─────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
if (e.code === 'Space') { e.preventDefault(); btnPlay.click(); }
else if (e.code === 'ArrowRight') btnNext.click();
else if (e.code === 'ArrowLeft') btnPrev.click();
});
// ── BOOT ───────────────────────────────────────────────────────────
initVoices();

181
static/index.html Normal file
View file

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Speechify</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="app">
<!-- Top Bar -->
<header class="topbar">
<div class="topbar-left">
<div class="logo">
<svg width="30" height="30" viewBox="0 0 30 30" fill="none">
<rect width="30" height="30" rx="9" fill="#7c3aed"/>
<polygon points="11,8 23,15 11,22" fill="white"/>
</svg>
<span>Speechify</span>
</div>
</div>
<div class="topbar-right">
<button class="btn-icon" id="btn-sidebar-toggle" title="Toggle Input Panel">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>
</button>
<button class="btn-icon" id="btn-settings-toggle" title="Voice Settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</div>
</header>
<div class="main-layout">
<!-- Left Sidebar: Input -->
<aside class="sidebar-left" id="sidebar-left">
<div class="sidebar-header"><h3>Add Content</h3></div>
<div class="tabs">
<button class="tab active" data-tab="text">Text</button>
<button class="tab" data-tab="pdf">PDF</button>
<button class="tab" data-tab="url">URL</button>
</div>
<!-- Text Tab -->
<div class="tab-content active" id="tab-text">
<textarea id="text-input" placeholder="Paste your text here..."></textarea>
<button class="btn-primary" id="btn-load-text">Load Text</button>
</div>
<!-- PDF Tab -->
<div class="tab-content" id="tab-pdf">
<div class="upload-area" id="upload-area">
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
<p>Drop PDF here or<br><strong>click to upload</strong></p>
<input type="file" id="pdf-input" accept=".pdf" hidden />
</div>
<div id="pdf-status" class="status-text"></div>
</div>
<!-- URL Tab -->
<div class="tab-content" id="tab-url">
<input type="url" id="url-input" class="text-field" placeholder="https://example.com/article" />
<button class="btn-primary" id="btn-load-url">Fetch Article</button>
<div id="url-status" class="status-text"></div>
</div>
</aside>
<!-- Main Content -->
<main class="content-area" id="content-area">
<div id="document-header" class="document-header" style="display:none">
<h1 class="document-title" id="document-title"></h1>
<p class="document-meta" id="document-meta"></p>
</div>
<div class="text-viewer" id="text-viewer">
<div class="empty-state">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<p>Load a document to get started</p>
<span>Paste text, upload a PDF, or enter a URL</span>
</div>
</div>
</main>
<!-- Right Sidebar: Voice Settings -->
<aside class="sidebar-right" id="sidebar-right">
<div class="sidebar-header"><h3>Voice Settings</h3></div>
<div class="setting-group">
<label class="setting-label">TTS Model</label>
<select id="model-select" class="select"></select>
</div>
<div class="setting-group">
<label class="setting-label">Voice</label>
<select id="voice-select" class="select"></select>
</div>
<div class="setting-group">
<label class="setting-label">Speed — <span id="speed-label">1x</span></label>
<input type="range" id="speed-slider" min="0.5" max="3" step="0.25" value="1" class="slider" />
<div class="speed-presets">
<button class="speed-btn" data-speed="0.75">0.75×</button>
<button class="speed-btn active" data-speed="1">1×</button>
<button class="speed-btn" data-speed="1.25">1.25×</button>
<button class="speed-btn" data-speed="1.5">1.5×</button>
<button class="speed-btn" data-speed="2">2×</button>
<button class="speed-btn" data-speed="3">3×</button>
</div>
</div>
<div class="setting-group">
<label class="setting-label">Font Size</label>
<div class="font-size-row">
<button class="btn-sz" id="font-decrease">A</button>
<span id="font-size-label">18px</span>
<button class="btn-sz" id="font-increase">A+</button>
</div>
</div>
<div class="setting-group">
<button class="btn-secondary" id="btn-preview">▶ Preview Voice</button>
</div>
</aside>
</div><!-- /main-layout -->
<!-- Player Bar -->
<div class="player-bar">
<div class="player-left">
<p class="now-playing" id="now-playing">No document loaded</p>
</div>
<div class="player-center">
<button class="ctrl-btn" id="btn-prev" title="Previous sentence (←)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
<button class="play-btn" id="btn-play" title="Play / Pause (Space)">
<svg class="icon-play" width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="icon-pause" width="26" height="26" viewBox="0 0 24 24" fill="currentColor" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<button class="ctrl-btn" id="btn-next" title="Next sentence (→)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 5.5 4V8l-5.5 4zM16 6h2v12h-2z"/></svg>
</button>
</div>
<div class="player-right">
<span class="progress-text"><span id="cur-sent">0</span> / <span id="tot-sent">0</span></span>
<div class="progress-track" id="progress-track">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
</div>
</div><!-- /app -->
<!-- Loading overlay -->
<div class="loading-overlay" id="loading-overlay">
<div class="spinner"></div>
<p id="loading-text">Loading…</p>
</div>
<script src="/static/app.js"></script>
</body>
</html>

251
static/style.css Normal file
View file

@ -0,0 +1,251 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0d;
--bg2: #161616;
--bg3: #212121;
--bg4: #2a2a2a;
--accent: #7c3aed;
--accent-h: #9d6fff;
--text: #e8e8e8;
--text-m: #888;
--text-d: #444;
--hl: rgba(124,58,237,.18);
--hl-active: rgba(124,58,237,.42);
--border: #232323;
--topbar-h: 54px;
--player-h: 76px;
--sidebar-w: 270px;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
/* ── TOP BAR ─────────────────────────────── */
.topbar {
height: var(--topbar-h);
background: var(--bg2);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
flex-shrink: 0;
}
.logo { display: flex; align-items: center; gap: 10px; font-size: 17px; font-weight: 700; }
.topbar-right { display: flex; gap: 6px; }
/* ── MAIN LAYOUT ──────────────────────────── */
.app { display: flex; flex-direction: column; height: 100vh; }
.main-layout {
display: flex;
flex: 1;
overflow: hidden;
height: calc(100vh - var(--topbar-h) - var(--player-h));
}
/* ── SIDEBARS ─────────────────────────────── */
.sidebar-left, .sidebar-right {
width: var(--sidebar-w);
background: var(--bg2);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
transition: width .25s ease;
}
.sidebar-left { border-right: 1px solid var(--border); }
.sidebar-right { border-left: 1px solid var(--border); overflow-y: auto; }
.sidebar-left.collapsed, .sidebar-right.collapsed { width: 0; }
.sidebar-header {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-header h3 {
font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: .6px; color: var(--text-m);
}
/* ── TABS ─────────────────────────────────── */
.tabs { display: flex; padding: 10px 10px 0; gap: 4px; flex-shrink: 0; }
.tab {
flex: 1; padding: 7px 4px; background: none; border: none;
border-radius: 7px; color: var(--text-m); font-size: 13px;
font-weight: 500; cursor: pointer; transition: all .15s; font-family: inherit;
}
.tab:hover { background: var(--bg3); color: var(--text); }
.tab.active { background: var(--accent); color: #fff; }
.tab-content { display: none; flex-direction: column; gap: 10px; padding: 12px; flex: 1; overflow: hidden; }
.tab-content.active { display: flex; }
/* ── FORM ELEMENTS ────────────────────────── */
textarea, .text-field {
background: var(--bg3); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-family: inherit;
font-size: 13px; line-height: 1.6; padding: 10px 12px; outline: none;
transition: border-color .15s; width: 100%;
}
textarea { resize: none; flex: 1; min-height: 0; }
textarea:focus, .text-field:focus { border-color: var(--accent); }
/* ── UPLOAD AREA ──────────────────────────── */
.upload-area {
border: 2px dashed var(--border); border-radius: 10px;
padding: 28px 16px; display: flex; flex-direction: column;
align-items: center; gap: 10px; cursor: pointer;
color: var(--text-m); transition: all .15s; text-align: center;
}
.upload-area strong { color: var(--text); }
.upload-area:hover, .upload-area.drag-over {
border-color: var(--accent); background: rgba(124,58,237,.06); color: var(--text);
}
.upload-area p { font-size: 13px; line-height: 1.5; }
/* ── BUTTONS ──────────────────────────────── */
.btn-primary {
background: var(--accent); color: #fff; border: none; border-radius: 8px;
padding: 10px; font-family: inherit; font-size: 14px; font-weight: 500;
cursor: pointer; transition: background .15s; width: 100%; flex-shrink: 0;
}
.btn-primary:hover { background: var(--accent-h); }
.btn-secondary {
background: var(--bg3); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 10px; font-family: inherit; font-size: 13px;
font-weight: 500; cursor: pointer; transition: all .15s; width: 100%;
}
.btn-secondary:hover { border-color: var(--accent); color: var(--accent-h); }
.btn-icon {
background: none; border: none; color: var(--text-m); cursor: pointer;
padding: 7px; border-radius: 6px; display: flex; align-items: center; transition: all .15s;
}
.btn-icon:hover { background: var(--bg3); color: var(--text); }
.btn-sz {
background: var(--bg3); border: 1px solid var(--border); color: var(--text);
cursor: pointer; padding: 4px 12px; border-radius: 6px; font-size: 13px;
font-weight: 600; transition: all .15s; font-family: inherit;
}
.btn-sz:hover { border-color: var(--accent); color: var(--accent-h); }
/* ── STATUS TEXT ─────────────────────────── */
.status-text { font-size: 12px; color: var(--text-m); min-height: 18px; text-align: center; }
/* ── MAIN CONTENT AREA ───────────────────── */
.content-area {
flex: 1; overflow-y: auto; padding: 44px 56px;
}
.document-header { margin-bottom: 28px; }
.document-title { font-size: 22px; font-weight: 700; margin-bottom: 6px; }
.document-meta { font-size: 13px; color: var(--text-m); }
.text-viewer { font-size: 18px; line-height: 1.85; color: var(--text); }
.empty-state {
display: flex; flex-direction: column; align-items: center;
gap: 14px; padding: 80px 40px; color: var(--text-d); text-align: center;
}
.empty-state p { font-size: 16px; color: var(--text-m); }
.empty-state span { font-size: 13px; }
/* Sentence spans */
.s {
cursor: pointer; border-radius: 4px; padding: 2px 1px;
transition: background .12s; display: inline;
}
.s:hover { background: var(--hl); }
.s.active { background: var(--hl-active); color: #fff; border-radius: 4px; }
.para-gap { display: block; height: .9em; }
/* ── SETTINGS SIDEBAR ────────────────────── */
.setting-group { padding: 16px; border-bottom: 1px solid var(--border); }
.setting-label {
display: block; font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: .5px; color: var(--text-m); margin-bottom: 10px;
}
.select {
width: 100%; background: var(--bg3); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-family: inherit; font-size: 13px;
padding: 8px 10px; outline: none; cursor: pointer; transition: border-color .15s;
}
.select:focus { border-color: var(--accent); }
.slider { width: 100%; accent-color: var(--accent); cursor: pointer; margin-bottom: 10px; display: block; }
.speed-presets { display: flex; gap: 4px; flex-wrap: wrap; }
.speed-btn {
background: var(--bg3); border: 1px solid var(--border); color: var(--text-m);
border-radius: 6px; padding: 4px 8px; font-size: 12px; font-family: inherit;
cursor: pointer; transition: all .15s;
}
.speed-btn:hover, .speed-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.font-size-row { display: flex; align-items: center; gap: 12px; }
.font-size-row span { flex: 1; text-align: center; font-size: 13px; color: var(--text-m); }
/* ── PLAYER BAR ─────────────────────────── */
.player-bar {
height: var(--player-h); background: var(--bg2);
border-top: 1px solid var(--border); display: flex;
align-items: center; padding: 0 24px; gap: 20px; flex-shrink: 0;
}
.player-left { flex: 1; min-width: 0; }
.now-playing {
font-size: 13px; color: var(--text-m);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.player-center { display: flex; align-items: center; gap: 14px; }
.ctrl-btn {
background: none; border: none; color: var(--text-m); cursor: pointer;
padding: 8px; border-radius: 50%; display: flex; align-items: center;
transition: all .15s;
}
.ctrl-btn:hover { background: var(--bg3); color: var(--text); }
.play-btn {
width: 50px; height: 50px; border-radius: 50%; border: none;
background: var(--accent); color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all .15s; flex-shrink: 0;
}
.play-btn:hover { background: var(--accent-h); transform: scale(1.05); }
.player-right { flex: 1; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.progress-text { font-size: 12px; color: var(--text-m); }
.progress-track {
width: 180px; height: 4px; background: var(--bg4);
border-radius: 2px; cursor: pointer; overflow: hidden;
}
.progress-fill {
height: 100%; background: var(--accent); border-radius: 2px;
width: 0%; transition: width .3s ease;
}
/* ── LOADING OVERLAY ─────────────────────── */
.loading-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.65); z-index: 200;
flex-direction: column; align-items: center; justify-content: center; gap: 16px;
}
.loading-overlay.show { display: flex; }
.spinner {
width: 38px; height: 38px; border: 3px solid var(--bg4);
border-top-color: var(--accent); border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── SCROLLBAR ───────────────────────────── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }