Compare commits
10 Commits
a951e865b7
...
1fa8bdec1d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fa8bdec1d | ||
|
|
9d1429eeb2 | ||
|
|
27d6248833 | ||
|
|
6630cb31f6 | ||
|
|
4c363a650c | ||
|
|
d78d1faf66 | ||
|
|
065ccd05a6 | ||
|
|
bfc54f4acd | ||
|
|
7c5eb17a14 | ||
|
|
cbd375c8e3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -163,3 +163,4 @@ cython_debug/
|
|||||||
*.mp3
|
*.mp3
|
||||||
*.srt
|
*.srt
|
||||||
/.idea/
|
/.idea/
|
||||||
|
.DS_Store
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
6
main.py
Normal file
6
main.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from edge-tts!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
7
pyproject.toml
Normal file
7
pyproject.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[project]
|
||||||
|
name = "edge-tts"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = []
|
||||||
@@ -6,13 +6,12 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from shutil import which
|
from shutil import which
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from .util import pr_err
|
from .util import pr_err
|
||||||
|
|
||||||
|
|
||||||
def _main() -> None:
|
def _parse_args() -> Tuple[bool, List[str]]:
|
||||||
depcheck_failed = False
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="edge-playback",
|
prog="edge-playback",
|
||||||
description="Speak text using Microsoft Edge's online text-to-speech API",
|
description="Speak text using Microsoft Edge's online text-to-speech API",
|
||||||
@@ -24,9 +23,12 @@ def _main() -> None:
|
|||||||
help="Use mpv to play audio. By default, false on Windows and true on all other platforms",
|
help="Use mpv to play audio. By default, false on Windows and true on all other platforms",
|
||||||
)
|
)
|
||||||
args, tts_args = parser.parse_known_args()
|
args, tts_args = parser.parse_known_args()
|
||||||
|
|
||||||
use_mpv = sys.platform != "win32" or args.mpv
|
use_mpv = sys.platform != "win32" or args.mpv
|
||||||
|
return use_mpv, tts_args
|
||||||
|
|
||||||
|
|
||||||
|
def _check_deps(use_mpv: bool) -> None:
|
||||||
|
depcheck_failed = False
|
||||||
deps = ["edge-tts"]
|
deps = ["edge-tts"]
|
||||||
if use_mpv:
|
if use_mpv:
|
||||||
deps.append("mpv")
|
deps.append("mpv")
|
||||||
@@ -40,54 +42,88 @@ def _main() -> None:
|
|||||||
pr_err("Please install the missing dependencies.")
|
pr_err("Please install the missing dependencies.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_temp_files(
|
||||||
|
use_mpv: bool, mp3_fname: Optional[str], srt_fname: Optional[str], debug: bool
|
||||||
|
) -> Tuple[str, Optional[str]]:
|
||||||
|
media = subtitle = None
|
||||||
|
if not mp3_fname:
|
||||||
|
media = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
|
||||||
|
media.close()
|
||||||
|
mp3_fname = media.name
|
||||||
|
if debug:
|
||||||
|
print(f"Media file: {mp3_fname}")
|
||||||
|
|
||||||
|
if not srt_fname and use_mpv:
|
||||||
|
subtitle = tempfile.NamedTemporaryFile(suffix=".srt", delete=False)
|
||||||
|
subtitle.close()
|
||||||
|
srt_fname = subtitle.name
|
||||||
|
|
||||||
|
if debug and srt_fname:
|
||||||
|
print(f"Subtitle file: {srt_fname}\n")
|
||||||
|
|
||||||
|
return mp3_fname, srt_fname
|
||||||
|
|
||||||
|
|
||||||
|
def _run_edge_tts(
|
||||||
|
mp3_fname: str, srt_fname: Optional[str], tts_args: List[str]
|
||||||
|
) -> None:
|
||||||
|
edge_tts_cmd = ["edge-tts", f"--write-media={mp3_fname}"]
|
||||||
|
if srt_fname:
|
||||||
|
edge_tts_cmd.append(f"--write-subtitles={srt_fname}")
|
||||||
|
edge_tts_cmd = edge_tts_cmd + tts_args
|
||||||
|
with subprocess.Popen(edge_tts_cmd) as process:
|
||||||
|
process.communicate()
|
||||||
|
|
||||||
|
|
||||||
|
def _play_media(use_mpv: bool, mp3_fname: str, srt_fname: Optional[str]) -> None:
|
||||||
|
if sys.platform == "win32" and not use_mpv:
|
||||||
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from .win32_playback import play_mp3_win32
|
||||||
|
|
||||||
|
play_mp3_win32(mp3_fname)
|
||||||
|
return
|
||||||
|
|
||||||
|
mpv_cmd = [
|
||||||
|
"mpv",
|
||||||
|
"--msg-level=all=error,statusline=status",
|
||||||
|
]
|
||||||
|
if srt_fname:
|
||||||
|
mpv_cmd.append(f"--sub-file={srt_fname}")
|
||||||
|
mpv_cmd.append(mp3_fname)
|
||||||
|
with subprocess.Popen(mpv_cmd) as process:
|
||||||
|
process.communicate()
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup(mp3_fname: Optional[str], srt_fname: Optional[str], keep: bool) -> None:
|
||||||
|
if keep and mp3_fname is not None:
|
||||||
|
print(f"\nKeeping temporary files: {mp3_fname}", end="")
|
||||||
|
if srt_fname:
|
||||||
|
print(f" and {srt_fname}", end="")
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
if mp3_fname is not None and os.path.exists(mp3_fname):
|
||||||
|
os.unlink(mp3_fname)
|
||||||
|
if srt_fname is not None and os.path.exists(srt_fname):
|
||||||
|
os.unlink(srt_fname)
|
||||||
|
|
||||||
|
|
||||||
|
def _main() -> None:
|
||||||
|
use_mpv, tts_args = _parse_args()
|
||||||
|
_check_deps(use_mpv)
|
||||||
|
|
||||||
|
debug = os.environ.get("EDGE_PLAYBACK_DEBUG") is not None
|
||||||
keep = os.environ.get("EDGE_PLAYBACK_KEEP_TEMP") is not None
|
keep = os.environ.get("EDGE_PLAYBACK_KEEP_TEMP") is not None
|
||||||
mp3_fname = os.environ.get("EDGE_PLAYBACK_MP3_FILE")
|
mp3_fname = os.environ.get("EDGE_PLAYBACK_MP3_FILE")
|
||||||
srt_fname = os.environ.get("EDGE_PLAYBACK_SRT_FILE")
|
srt_fname = os.environ.get("EDGE_PLAYBACK_SRT_FILE")
|
||||||
media, subtitle = None, None
|
|
||||||
try:
|
try:
|
||||||
if not mp3_fname:
|
mp3_fname, srt_fname = _create_temp_files(use_mpv, mp3_fname, srt_fname, debug)
|
||||||
media = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
|
_run_edge_tts(mp3_fname, srt_fname, tts_args)
|
||||||
media.close()
|
_play_media(use_mpv, mp3_fname, srt_fname)
|
||||||
mp3_fname = media.name
|
|
||||||
|
|
||||||
if not srt_fname and use_mpv:
|
|
||||||
subtitle = tempfile.NamedTemporaryFile(suffix=".srt", delete=False)
|
|
||||||
subtitle.close()
|
|
||||||
srt_fname = subtitle.name
|
|
||||||
|
|
||||||
print(f"Media file: {mp3_fname}")
|
|
||||||
if srt_fname:
|
|
||||||
print(f"Subtitle file: {srt_fname}\n")
|
|
||||||
|
|
||||||
edge_tts_cmd = ["edge-tts", f"--write-media={mp3_fname}"]
|
|
||||||
if srt_fname:
|
|
||||||
edge_tts_cmd.append(f"--write-subtitles={srt_fname}")
|
|
||||||
edge_tts_cmd = edge_tts_cmd + tts_args
|
|
||||||
with subprocess.Popen(edge_tts_cmd) as process:
|
|
||||||
process.communicate()
|
|
||||||
|
|
||||||
if sys.platform == "win32" and not use_mpv:
|
|
||||||
# pylint: disable-next=import-outside-toplevel
|
|
||||||
from .win32_playback import play_mp3_win32
|
|
||||||
|
|
||||||
play_mp3_win32(mp3_fname)
|
|
||||||
else:
|
|
||||||
with subprocess.Popen(
|
|
||||||
[
|
|
||||||
"mpv",
|
|
||||||
f"--sub-file={srt_fname}",
|
|
||||||
mp3_fname,
|
|
||||||
]
|
|
||||||
) as process:
|
|
||||||
process.communicate()
|
|
||||||
finally:
|
finally:
|
||||||
if keep:
|
_cleanup(mp3_fname, srt_fname, keep)
|
||||||
print(f"\nKeeping temporary files: {mp3_fname} and {srt_fname}")
|
|
||||||
else:
|
|
||||||
if mp3_fname is not None and os.path.exists(mp3_fname):
|
|
||||||
os.unlink(mp3_fname)
|
|
||||||
if srt_fname is not None and os.path.exists(srt_fname):
|
|
||||||
os.unlink(srt_fname)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from typing import (
|
|||||||
Dict,
|
Dict,
|
||||||
Generator,
|
Generator,
|
||||||
List,
|
List,
|
||||||
Literal,
|
|
||||||
Optional,
|
Optional,
|
||||||
Tuple,
|
Tuple,
|
||||||
Union,
|
Union,
|
||||||
@@ -25,6 +24,7 @@ from xml.sax.saxutils import escape, unescape
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import certifi
|
import certifi
|
||||||
|
from typing_extensions import Literal
|
||||||
|
|
||||||
from .constants import DEFAULT_VOICE, SEC_MS_GEC_VERSION, WSS_HEADERS, WSS_URL
|
from .constants import DEFAULT_VOICE, SEC_MS_GEC_VERSION, WSS_HEADERS, WSS_URL
|
||||||
from .data_classes import TTSConfig
|
from .data_classes import TTSConfig
|
||||||
@@ -433,9 +433,9 @@ class Communicate:
|
|||||||
trust_env=True,
|
trust_env=True,
|
||||||
timeout=self.session_timeout,
|
timeout=self.session_timeout,
|
||||||
) as session, session.ws_connect(
|
) as session, session.ws_connect(
|
||||||
f"{WSS_URL}&Sec-MS-GEC={DRM.generate_sec_ms_gec()}"
|
f"{WSS_URL}&ConnectionId={connect_id()}"
|
||||||
f"&Sec-MS-GEC-Version={SEC_MS_GEC_VERSION}"
|
f"&Sec-MS-GEC={DRM.generate_sec_ms_gec()}"
|
||||||
f"&ConnectionId={connect_id()}",
|
f"&Sec-MS-GEC-Version={SEC_MS_GEC_VERSION}",
|
||||||
compress=15,
|
compress=15,
|
||||||
proxy=self.proxy,
|
proxy=self.proxy,
|
||||||
headers=WSS_HEADERS,
|
headers=WSS_HEADERS,
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
"""Constants for the edge_tts package."""
|
"""Constants for the edge_tts package."""
|
||||||
|
|
||||||
BASE_URL = "speech.platform.bing.com/consumer/speech/synthesize/readaloud"
|
BASE_URL = "api.msedgeservices.com/tts/cognitiveservices"
|
||||||
TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
|
TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
|
||||||
|
|
||||||
WSS_URL = f"wss://{BASE_URL}/edge/v1?TrustedClientToken={TRUSTED_CLIENT_TOKEN}"
|
WSS_URL = (
|
||||||
VOICE_LIST = f"https://{BASE_URL}/voices/list?trustedclienttoken={TRUSTED_CLIENT_TOKEN}"
|
f"wss://{BASE_URL}/websocket/v1?Ocp-Apim-Subscription-Key={TRUSTED_CLIENT_TOKEN}"
|
||||||
|
)
|
||||||
|
VOICE_LIST = (
|
||||||
|
f"https://{BASE_URL}/voices/list?Ocp-Apim-Subscription-Key={TRUSTED_CLIENT_TOKEN}"
|
||||||
|
)
|
||||||
|
|
||||||
DEFAULT_VOICE = "en-US-EmmaMultilingualNeural"
|
DEFAULT_VOICE = "en-US-EmmaMultilingualNeural"
|
||||||
|
|
||||||
CHROMIUM_FULL_VERSION = "130.0.2849.68"
|
CHROMIUM_FULL_VERSION = "140.0.3485.14"
|
||||||
CHROMIUM_MAJOR_VERSION = CHROMIUM_FULL_VERSION.split(".", maxsplit=1)[0]
|
CHROMIUM_MAJOR_VERSION = CHROMIUM_FULL_VERSION.split(".", maxsplit=1)[0]
|
||||||
SEC_MS_GEC_VERSION = f"1-{CHROMIUM_FULL_VERSION}"
|
SEC_MS_GEC_VERSION = f"1-{CHROMIUM_FULL_VERSION}"
|
||||||
BASE_HEADERS = {
|
BASE_HEADERS = {
|
||||||
@@ -22,6 +26,8 @@ WSS_HEADERS = {
|
|||||||
"Pragma": "no-cache",
|
"Pragma": "no-cache",
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Origin": "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
|
"Origin": "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
|
||||||
|
"Sec-WebSocket-Protocol": "synthesize",
|
||||||
|
"Sec-WebSocket-Version": "13",
|
||||||
}
|
}
|
||||||
WSS_HEADERS.update(BASE_HEADERS)
|
WSS_HEADERS.update(BASE_HEADERS)
|
||||||
VOICE_HEADERS = {
|
VOICE_HEADERS = {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
|
||||||
|
from typing_extensions import Literal
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -20,49 +20,8 @@ class TTSChunk(TypedDict):
|
|||||||
class VoiceTag(TypedDict):
|
class VoiceTag(TypedDict):
|
||||||
"""VoiceTag data."""
|
"""VoiceTag data."""
|
||||||
|
|
||||||
ContentCategories: List[
|
ContentCategories: List[str]
|
||||||
Literal[
|
VoicePersonalities: List[str]
|
||||||
"Cartoon",
|
|
||||||
"Conversation",
|
|
||||||
"Copilot",
|
|
||||||
"Dialect",
|
|
||||||
"General",
|
|
||||||
"News",
|
|
||||||
"Novel",
|
|
||||||
"Sports",
|
|
||||||
]
|
|
||||||
]
|
|
||||||
VoicePersonalities: List[
|
|
||||||
Literal[
|
|
||||||
"Approachable",
|
|
||||||
"Authentic",
|
|
||||||
"Authority",
|
|
||||||
"Bright",
|
|
||||||
"Caring",
|
|
||||||
"Casual",
|
|
||||||
"Cheerful",
|
|
||||||
"Clear",
|
|
||||||
"Comfort",
|
|
||||||
"Confident",
|
|
||||||
"Considerate",
|
|
||||||
"Conversational",
|
|
||||||
"Cute",
|
|
||||||
"Expressive",
|
|
||||||
"Friendly",
|
|
||||||
"Honest",
|
|
||||||
"Humorous",
|
|
||||||
"Lively",
|
|
||||||
"Passion",
|
|
||||||
"Pleasant",
|
|
||||||
"Positive",
|
|
||||||
"Professional",
|
|
||||||
"Rational",
|
|
||||||
"Reliable",
|
|
||||||
"Sincere",
|
|
||||||
"Sunshine",
|
|
||||||
"Warm",
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Voice(TypedDict):
|
class Voice(TypedDict):
|
||||||
@@ -70,11 +29,13 @@ class Voice(TypedDict):
|
|||||||
|
|
||||||
Name: str
|
Name: str
|
||||||
ShortName: str
|
ShortName: str
|
||||||
Gender: Literal["Female", "Male"]
|
DisplayName: str
|
||||||
|
LocalName: str
|
||||||
|
LocaleName: str
|
||||||
Locale: str
|
Locale: str
|
||||||
SuggestedCodec: Literal["audio-24khz-48kbitrate-mono-mp3"]
|
Gender: Literal["Female", "Male"]
|
||||||
FriendlyName: str
|
WordsPerMinute: str
|
||||||
Status: Literal["GA"]
|
Status: Literal["Deprecated", "GA", "Preview"]
|
||||||
VoiceTag: VoiceTag
|
VoiceTag: VoiceTag
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Version information for the edge_tts package."""
|
"""Version information for the edge_tts package."""
|
||||||
|
|
||||||
__version__ = "7.2.0"
|
__version__ = "7.2.3"
|
||||||
__version_info__ = tuple(int(num) for num in __version__.split("."))
|
__version_info__ = tuple(int(num) for num in __version__.split("."))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ correct voice based on their attributes."""
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import ssl
|
import ssl
|
||||||
from typing import List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import certifi
|
import certifi
|
||||||
@@ -38,20 +38,17 @@ async def __list_voices(
|
|||||||
ssl=ssl_ctx,
|
ssl=ssl_ctx,
|
||||||
raise_for_status=True,
|
raise_for_status=True,
|
||||||
) as url:
|
) as url:
|
||||||
data: List[Voice] = json.loads(await url.text())
|
data: List[Any] = json.loads(await url.text())
|
||||||
|
|
||||||
for voice in data:
|
for voice in data:
|
||||||
# Remove leading and trailing whitespace from categories and personalities.
|
if "VoiceTag" not in voice:
|
||||||
# This has only happened in one case with the zh-CN-YunjianNeural voice
|
voice["VoiceTag"] = {}
|
||||||
# where there was a leading space in one of the categories.
|
|
||||||
voice["VoiceTag"]["ContentCategories"] = [
|
if "ContentCategories" not in voice["VoiceTag"]:
|
||||||
category.strip() # type: ignore
|
voice["VoiceTag"]["ContentCategories"] = []
|
||||||
for category in voice["VoiceTag"]["ContentCategories"]
|
|
||||||
]
|
if "VoicePersonalities" not in voice["VoiceTag"]:
|
||||||
voice["VoiceTag"]["VoicePersonalities"] = [
|
voice["VoiceTag"]["VoicePersonalities"] = []
|
||||||
personality.strip() # type: ignore
|
|
||||||
for personality in voice["VoiceTag"]["VoicePersonalities"]
|
|
||||||
]
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
53
web/.dockerignore
Normal file
53
web/.dockerignore
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
QUICKSTART.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Build scripts
|
||||||
|
build.sh
|
||||||
|
deploy.sh
|
||||||
|
create_icons.py
|
||||||
|
create_icons.sh
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test_*.py
|
||||||
|
*_test.py
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
*.srt
|
||||||
26
web/.gitignore
vendored
Normal file
26
web/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
*.srt
|
||||||
|
create_icons.py
|
||||||
|
create_icons.sh
|
||||||
435
web/DEPLOYMENT.md
Normal file
435
web/DEPLOYMENT.md
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
# Edge TTS Web UI - Docker Deployment Guide
|
||||||
|
|
||||||
|
Complete guide for deploying Edge TTS Web UI to a remote server using Docker.
|
||||||
|
|
||||||
|
## 📋 Prerequisites
|
||||||
|
|
||||||
|
### Local Machine
|
||||||
|
- Docker installed
|
||||||
|
- Docker Compose installed
|
||||||
|
- SSH access to remote server
|
||||||
|
- rsync (for deployment script)
|
||||||
|
|
||||||
|
### Remote Server
|
||||||
|
- Linux server (Ubuntu 20.04+ recommended)
|
||||||
|
- Docker installed
|
||||||
|
- Docker Compose installed
|
||||||
|
- Port 8000 open (or configure different port)
|
||||||
|
- Minimum 512MB RAM
|
||||||
|
- SSH access configured
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Option 1: Local Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
|
||||||
|
# Build the image
|
||||||
|
./build.sh
|
||||||
|
|
||||||
|
# Start with docker-compose
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Access at http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Remote Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
|
||||||
|
# Deploy to remote server
|
||||||
|
REMOTE_HOST=192.168.1.100 ./deploy.sh
|
||||||
|
|
||||||
|
# Or with custom user and path
|
||||||
|
REMOTE_HOST=myserver.com REMOTE_USER=deployer REMOTE_PATH=/opt/edge-tts ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Building the Docker Image
|
||||||
|
|
||||||
|
### Build Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build with default tag (latest)
|
||||||
|
./build.sh
|
||||||
|
|
||||||
|
# Build with custom tag
|
||||||
|
./build.sh v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t edge-tts-web:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏃 Running the Container
|
||||||
|
|
||||||
|
### Using Docker Compose (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start in background
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# View status
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Docker CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run container
|
||||||
|
docker run -d \
|
||||||
|
--name edge-tts-web \
|
||||||
|
-p 8000:8000 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
edge-tts-web:latest
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker logs -f edge-tts-web
|
||||||
|
|
||||||
|
# Stop container
|
||||||
|
docker stop edge-tts-web
|
||||||
|
|
||||||
|
# Remove container
|
||||||
|
docker rm edge-tts-web
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 Remote Server Deployment
|
||||||
|
|
||||||
|
### Step-by-Step Manual Deployment
|
||||||
|
|
||||||
|
#### 1. Install Docker on Remote Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into server
|
||||||
|
ssh user@your-server.com
|
||||||
|
|
||||||
|
# Install Docker
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sudo sh get-docker.sh
|
||||||
|
|
||||||
|
# Install Docker Compose
|
||||||
|
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||||
|
sudo chmod +x /usr/local/bin/docker-compose
|
||||||
|
|
||||||
|
# Add user to docker group (optional)
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Copy Files to Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create deployment directory
|
||||||
|
ssh user@your-server.com 'mkdir -p /opt/edge-tts'
|
||||||
|
|
||||||
|
# Copy files (from local machine)
|
||||||
|
cd web
|
||||||
|
rsync -avz --exclude='venv' --exclude='.git' \
|
||||||
|
./ user@your-server.com:/opt/edge-tts/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Build and Start on Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into server
|
||||||
|
ssh user@your-server.com
|
||||||
|
|
||||||
|
# Navigate to deployment directory
|
||||||
|
cd /opt/edge-tts
|
||||||
|
|
||||||
|
# Build and start
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
docker-compose ps
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automated Deployment Script
|
||||||
|
|
||||||
|
The `deploy.sh` script automates the entire deployment process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic usage
|
||||||
|
REMOTE_HOST=192.168.1.100 ./deploy.sh
|
||||||
|
|
||||||
|
# With custom configuration
|
||||||
|
REMOTE_HOST=myserver.com \
|
||||||
|
REMOTE_USER=deployer \
|
||||||
|
REMOTE_PATH=/home/deployer/edge-tts \
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the script does:**
|
||||||
|
1. ✅ Checks SSH connectivity
|
||||||
|
2. ✅ Creates remote directory
|
||||||
|
3. ✅ Copies all files to server
|
||||||
|
4. ✅ Stops existing containers
|
||||||
|
5. ✅ Builds new Docker image
|
||||||
|
6. ✅ Starts containers
|
||||||
|
7. ✅ Shows deployment status
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file for custom configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
# Add other environment variables as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Port
|
||||||
|
|
||||||
|
To use a different port, edit `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "3000:8000" # Host:Container
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Limits
|
||||||
|
|
||||||
|
Add resource limits in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
edge-tts-web:
|
||||||
|
# ... other config
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 512M
|
||||||
|
reservations:
|
||||||
|
cpus: '0.25'
|
||||||
|
memory: 256M
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Security Best Practices
|
||||||
|
|
||||||
|
### 1. Use Non-Root User
|
||||||
|
The Dockerfile already creates a non-root user (`appuser`)
|
||||||
|
|
||||||
|
### 2. Reverse Proxy with SSL
|
||||||
|
|
||||||
|
Use Nginx or Traefik as reverse proxy:
|
||||||
|
|
||||||
|
**Nginx example:**
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name tts.yourdomain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**With Let's Encrypt SSL:**
|
||||||
|
```bash
|
||||||
|
sudo apt install certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d tts.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Firewall Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Allow only necessary ports
|
||||||
|
sudo ufw allow 22/tcp # SSH
|
||||||
|
sudo ufw allow 80/tcp # HTTP
|
||||||
|
sudo ufw allow 443/tcp # HTTPS
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Docker Socket Security
|
||||||
|
|
||||||
|
Don't expose Docker socket unnecessarily. The current setup doesn't require it.
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### Check Container Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using docker-compose
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Using docker CLI
|
||||||
|
docker ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All logs
|
||||||
|
docker-compose logs
|
||||||
|
|
||||||
|
# Follow logs (real-time)
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Last 100 lines
|
||||||
|
docker-compose logs --tail=100
|
||||||
|
|
||||||
|
# Specific service
|
||||||
|
docker-compose logs edge-tts-web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
The container includes a health check that runs every 30 seconds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check health status
|
||||||
|
docker inspect edge-tts-web --format='{{.State.Health.Status}}'
|
||||||
|
|
||||||
|
# View health check logs
|
||||||
|
docker inspect edge-tts-web --format='{{json .State.Health}}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Updates and Maintenance
|
||||||
|
|
||||||
|
### Update Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest changes
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# Rebuild and restart
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Or use deployment script
|
||||||
|
REMOTE_HOST=your-server.com ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup and Restore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup (no persistent data currently)
|
||||||
|
# If you add persistent data, use Docker volumes
|
||||||
|
|
||||||
|
# Create volume backup
|
||||||
|
docker run --rm -v edge-tts-data:/data -v $(pwd):/backup \
|
||||||
|
alpine tar czf /backup/edge-tts-backup.tar.gz /data
|
||||||
|
|
||||||
|
# Restore volume
|
||||||
|
docker run --rm -v edge-tts-data:/data -v $(pwd):/backup \
|
||||||
|
alpine tar xzf /backup/edge-tts-backup.tar.gz -C /
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean Up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop and remove containers
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Remove images
|
||||||
|
docker rmi edge-tts-web:latest
|
||||||
|
|
||||||
|
# Clean up unused resources
|
||||||
|
docker system prune -a
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Container Won't Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
docker-compose logs
|
||||||
|
|
||||||
|
# Check container status
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Rebuild from scratch
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check what's using the port
|
||||||
|
sudo lsof -i :8000
|
||||||
|
|
||||||
|
# Kill the process or change port in docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Denied Errors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On remote server, add user to docker group
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
newgrp docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check Failing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if app is responding
|
||||||
|
curl http://localhost:8000/api/health
|
||||||
|
|
||||||
|
# Check health status
|
||||||
|
docker inspect edge-tts-web --format='{{json .State.Health}}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── Dockerfile # Docker image definition
|
||||||
|
├── docker-compose.yml # Docker Compose configuration
|
||||||
|
├── .dockerignore # Files to exclude from image
|
||||||
|
├── build.sh # Build script
|
||||||
|
├── deploy.sh # Deployment script
|
||||||
|
├── server.py # FastAPI server
|
||||||
|
├── index.html # Web UI
|
||||||
|
├── app.js # Frontend logic
|
||||||
|
├── styles.css # Styling
|
||||||
|
├── manifest.json # PWA manifest
|
||||||
|
├── sw.js # Service worker
|
||||||
|
└── requirements.txt # Python dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌟 Production Recommendations
|
||||||
|
|
||||||
|
1. **Use a Reverse Proxy**: Nginx or Traefik with SSL/TLS
|
||||||
|
2. **Set Up Monitoring**: Prometheus + Grafana
|
||||||
|
3. **Configure Logging**: Centralized logging with ELK or Loki
|
||||||
|
4. **Auto-Restart**: Use `restart: unless-stopped` in docker-compose
|
||||||
|
5. **Resource Limits**: Set appropriate CPU and memory limits
|
||||||
|
6. **Regular Backups**: If you add persistent data
|
||||||
|
7. **Security Updates**: Keep Docker and base images updated
|
||||||
|
8. **Domain Name**: Use a proper domain with DNS
|
||||||
|
9. **CDN**: Consider using a CDN for static assets
|
||||||
|
10. **Rate Limiting**: Implement rate limiting for API endpoints
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
- Check logs: `docker-compose logs -f`
|
||||||
|
- GitHub Issues: https://github.com/rany2/edge-tts/issues
|
||||||
|
- Review health checks: `docker inspect edge-tts-web`
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
This deployment configuration is part of the Edge TTS project.
|
||||||
195
web/DOCKER_QUICKSTART.md
Normal file
195
web/DOCKER_QUICKSTART.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Docker Quick Start Guide
|
||||||
|
|
||||||
|
## 🚀 Deploy in 3 Steps
|
||||||
|
|
||||||
|
### Step 1: Build
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Deploy to Remote Server
|
||||||
|
```bash
|
||||||
|
# Replace with your server IP/hostname
|
||||||
|
REMOTE_HOST=192.168.1.100 ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Access
|
||||||
|
```
|
||||||
|
http://YOUR_SERVER_IP:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Common Commands
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
```bash
|
||||||
|
# Build and start
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remote Deployment
|
||||||
|
```bash
|
||||||
|
# Basic deployment
|
||||||
|
REMOTE_HOST=192.168.1.100 ./deploy.sh
|
||||||
|
|
||||||
|
# Custom user and path
|
||||||
|
REMOTE_HOST=myserver.com \
|
||||||
|
REMOTE_USER=deployer \
|
||||||
|
REMOTE_PATH=/opt/edge-tts \
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
```bash
|
||||||
|
# Container status
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
docker inspect edge-tts-web --format='{{.State.Health.Status}}'
|
||||||
|
|
||||||
|
# Resource usage
|
||||||
|
docker stats edge-tts-web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
```bash
|
||||||
|
# View logs
|
||||||
|
docker-compose logs --tail=100
|
||||||
|
|
||||||
|
# Restart container
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# Rebuild from scratch
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Change Port
|
||||||
|
Edit `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "3000:8000" # Change 3000 to your desired port
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
Create `.env` file:
|
||||||
|
```bash
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
# Add your variables here
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 Production Setup
|
||||||
|
|
||||||
|
### 1. Use Reverse Proxy (Recommended)
|
||||||
|
|
||||||
|
**Nginx:**
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name tts.yourdomain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Install SSL:**
|
||||||
|
```bash
|
||||||
|
sudo certbot --nginx -d tts.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Firewall Setup
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 22/tcp # SSH
|
||||||
|
sudo ufw allow 80/tcp # HTTP
|
||||||
|
sudo ufw allow 443/tcp # HTTPS
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Auto-Updates (Optional)
|
||||||
|
```bash
|
||||||
|
# Add to crontab
|
||||||
|
0 2 * * * cd /opt/edge-tts && docker-compose pull && docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### Check Health
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Metrics
|
||||||
|
```bash
|
||||||
|
docker stats edge-tts-web
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🆘 Quick Fixes
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
```bash
|
||||||
|
# Find process using port
|
||||||
|
sudo lsof -i :8000
|
||||||
|
|
||||||
|
# Or change port in docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Denied
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
newgrp docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Won't Start
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
docker-compose logs
|
||||||
|
|
||||||
|
# Rebuild
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 File Structure
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── Dockerfile # Container definition
|
||||||
|
├── docker-compose.yml # Orchestration
|
||||||
|
├── build.sh # Build script
|
||||||
|
├── deploy.sh # Deploy script
|
||||||
|
├── server.py # Backend
|
||||||
|
└── [web files] # Frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 Useful Links
|
||||||
|
|
||||||
|
- Full Documentation: [DEPLOYMENT.md](DEPLOYMENT.md)
|
||||||
|
- Edge TTS Project: https://github.com/rany2/edge-tts
|
||||||
|
- Docker Docs: https://docs.docker.com
|
||||||
|
|
||||||
|
## 💡 Tips
|
||||||
|
|
||||||
|
1. **Always use reverse proxy in production**
|
||||||
|
2. **Enable SSL/TLS with Let's Encrypt**
|
||||||
|
3. **Set up monitoring and logging**
|
||||||
|
4. **Regular backups if you add persistent data**
|
||||||
|
5. **Keep Docker and images updated**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Need Help?** Check [DEPLOYMENT.md](DEPLOYMENT.md) for detailed instructions!
|
||||||
42
web/Dockerfile
Normal file
42
web/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Edge TTS Web UI - Production Dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY *.py .
|
||||||
|
COPY *.html .
|
||||||
|
COPY *.css .
|
||||||
|
COPY *.js .
|
||||||
|
COPY *.json .
|
||||||
|
COPY *.png .
|
||||||
|
COPY *.svg .
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN useradd -m -u 1000 appuser && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "server.py", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
212
web/QUICKSTART.md
Normal file
212
web/QUICKSTART.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# Quick Start Guide
|
||||||
|
|
||||||
|
## 🚀 Get Started in 3 Steps
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
```bash
|
||||||
|
python3 server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Open Your Browser
|
||||||
|
|
||||||
|
Visit: **http://localhost:8000**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features at a Glance
|
||||||
|
|
||||||
|
### Text to Speech
|
||||||
|
- Enter any text (up to 5000 characters)
|
||||||
|
- Select from 100+ voices in multiple languages
|
||||||
|
- Adjust speed, volume, and pitch
|
||||||
|
- Generate natural-sounding speech
|
||||||
|
|
||||||
|
### Voice Selection
|
||||||
|
- Filter by language and gender
|
||||||
|
- Preview voice names and locales
|
||||||
|
- Save your favorite settings
|
||||||
|
|
||||||
|
### Audio Controls
|
||||||
|
- Play audio directly in browser
|
||||||
|
- Download as MP3 files
|
||||||
|
- View generation history
|
||||||
|
- Quick reload from history
|
||||||
|
|
||||||
|
### PWA Features
|
||||||
|
- Install as standalone app
|
||||||
|
- Offline support with service worker
|
||||||
|
- Works on desktop and mobile
|
||||||
|
- Responsive design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Install as App
|
||||||
|
|
||||||
|
### On Desktop (Chrome/Edge)
|
||||||
|
1. Click the install icon in the address bar
|
||||||
|
2. Or look for "Install App" button in the UI
|
||||||
|
3. App will be added to your applications
|
||||||
|
|
||||||
|
### On Mobile (Android)
|
||||||
|
1. Open in Chrome
|
||||||
|
2. Tap the menu (⋮)
|
||||||
|
3. Select "Add to Home screen"
|
||||||
|
4. App icon will appear on home screen
|
||||||
|
|
||||||
|
### On iOS (Safari)
|
||||||
|
1. Tap the share button
|
||||||
|
2. Select "Add to Home Screen"
|
||||||
|
3. Name the app and add to home screen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Quick Usage Tips
|
||||||
|
|
||||||
|
### Generate Speech
|
||||||
|
1. Enter or paste text
|
||||||
|
2. Select a voice (default: English)
|
||||||
|
3. Adjust speed/volume/pitch if needed
|
||||||
|
4. Click "Generate Speech"
|
||||||
|
5. Audio player appears with playback controls
|
||||||
|
|
||||||
|
### Download Audio
|
||||||
|
- Click "Download MP3" button
|
||||||
|
- File saves with timestamp and text snippet
|
||||||
|
|
||||||
|
### Use History
|
||||||
|
- Recent generations saved automatically
|
||||||
|
- Click "Load" to restore settings
|
||||||
|
- Click "Delete" to remove from history
|
||||||
|
|
||||||
|
### Filter Voices
|
||||||
|
- Use language dropdown for specific locales
|
||||||
|
- Use gender filter for Male/Female voices
|
||||||
|
- Voice list updates automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Change Port
|
||||||
|
```bash
|
||||||
|
python3 server.py --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable Hot Reload (Development)
|
||||||
|
```bash
|
||||||
|
python3 server.py --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bind to Specific Host
|
||||||
|
```bash
|
||||||
|
python3 server.py --host 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ API Usage
|
||||||
|
|
||||||
|
### Test with cURL
|
||||||
|
|
||||||
|
Get voices:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/voices
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate speech:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/synthesize \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"text": "Hello, world!",
|
||||||
|
"voice": "en-US-EmmaMultilingualNeural",
|
||||||
|
"rate": "+0%",
|
||||||
|
"volume": "+0%",
|
||||||
|
"pitch": "+0Hz"
|
||||||
|
}' \
|
||||||
|
--output speech.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Customization
|
||||||
|
|
||||||
|
### Update Theme Color
|
||||||
|
|
||||||
|
Edit `styles.css`:
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--primary-color: #2563eb; /* Your color here */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `manifest.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"theme_color": "#2563eb"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replace Icons
|
||||||
|
|
||||||
|
Create PNG icons:
|
||||||
|
- `icon-192.png` - 192x192 pixels
|
||||||
|
- `icon-512.png` - 512x512 pixels
|
||||||
|
|
||||||
|
Use any image editing tool or online icon generator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
```bash
|
||||||
|
python3 server.py --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies Not Found
|
||||||
|
```bash
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Voices Not Loading
|
||||||
|
- Check internet connection
|
||||||
|
- Check server logs for errors
|
||||||
|
- Try refreshing the page
|
||||||
|
|
||||||
|
### Service Worker Issues
|
||||||
|
- Clear browser cache
|
||||||
|
- Hard refresh (Ctrl+Shift+R or Cmd+Shift+R)
|
||||||
|
- Check browser console for errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 More Information
|
||||||
|
|
||||||
|
See [README.md](README.md) for detailed documentation including:
|
||||||
|
- Full API reference
|
||||||
|
- Deployment guide
|
||||||
|
- Docker setup
|
||||||
|
- Production considerations
|
||||||
|
- Contributing guidelines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 You're All Set!
|
||||||
|
|
||||||
|
Enjoy using Edge TTS Web UI!
|
||||||
|
|
||||||
|
For issues or questions, visit: https://github.com/rany2/edge-tts
|
||||||
297
web/README.md
Normal file
297
web/README.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# Edge TTS Web UI
|
||||||
|
|
||||||
|
A Progressive Web App (PWA) for converting text to speech using Microsoft Edge's online TTS service.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎙️ **Text to Speech**: Convert any text to natural-sounding speech
|
||||||
|
- 🌍 **Multiple Languages**: Support for 100+ voices in various languages
|
||||||
|
- 🎛️ **Voice Customization**: Adjust speed, volume, and pitch
|
||||||
|
- 📱 **PWA Support**: Install as an app on any device
|
||||||
|
- 💾 **Offline Support**: Service worker caching for offline usage
|
||||||
|
- 📝 **History**: Keep track of recent generations
|
||||||
|
- ⬇️ **Download**: Save generated audio as MP3 files
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.8 or higher
|
||||||
|
- pip (Python package manager)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Navigate to the web directory:
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Start the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with custom options:
|
||||||
|
```bash
|
||||||
|
python server.py --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--host`: Host to bind to (default: 0.0.0.0)
|
||||||
|
- `--port`: Port to bind to (default: 8000)
|
||||||
|
- `--reload`: Enable auto-reload for development
|
||||||
|
|
||||||
|
### Access the Web UI
|
||||||
|
|
||||||
|
Open your browser and navigate to:
|
||||||
|
```
|
||||||
|
http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install as PWA
|
||||||
|
|
||||||
|
1. Open the web UI in a modern browser (Chrome, Edge, Safari, Firefox)
|
||||||
|
2. Look for the install prompt or click "Install App" button
|
||||||
|
3. The app will be added to your home screen/app drawer
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The server provides the following REST API endpoints:
|
||||||
|
|
||||||
|
### GET /api/health
|
||||||
|
Health check endpoint
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "edge-tts-api"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/voices
|
||||||
|
Get list of all available voices
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Name": "en-US-EmmaMultilingualNeural",
|
||||||
|
"ShortName": "en-US-EmmaMultilingualNeural",
|
||||||
|
"Gender": "Female",
|
||||||
|
"Locale": "en-US",
|
||||||
|
"LocaleName": "English (United States)",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/synthesize
|
||||||
|
Synthesize speech from text
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "Hello, world!",
|
||||||
|
"voice": "en-US-EmmaMultilingualNeural",
|
||||||
|
"rate": "+0%",
|
||||||
|
"volume": "+0%",
|
||||||
|
"pitch": "+0Hz"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Returns MP3 audio file
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `text` (required): Text to convert (max 5000 characters)
|
||||||
|
- `voice` (optional): Voice name (default: "en-US-EmmaMultilingualNeural")
|
||||||
|
- `rate` (optional): Speech rate from -100% to +100% (default: "+0%")
|
||||||
|
- `volume` (optional): Volume from -100% to +100% (default: "+0%")
|
||||||
|
- `pitch` (optional): Pitch from -500Hz to +500Hz (default: "+0Hz")
|
||||||
|
|
||||||
|
### POST /api/synthesize-with-subtitles
|
||||||
|
Synthesize speech with subtitle generation
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
Same as /api/synthesize
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"audio": "base64_encoded_audio_data",
|
||||||
|
"subtitles": "SRT formatted subtitles",
|
||||||
|
"format": "mp3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── index.html # Main HTML page
|
||||||
|
├── styles.css # Styles and theme
|
||||||
|
├── app.js # Client-side JavaScript
|
||||||
|
├── manifest.json # PWA manifest
|
||||||
|
├── sw.js # Service worker
|
||||||
|
├── server.py # FastAPI backend server
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── icon-192.png # App icon (192x192)
|
||||||
|
├── icon-512.png # App icon (512x512)
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running in Development Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
This enables auto-reload when you modify the code.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Test the API endpoints using curl:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get voices
|
||||||
|
curl http://localhost:8000/api/voices
|
||||||
|
|
||||||
|
# Synthesize speech
|
||||||
|
curl -X POST http://localhost:8000/api/synthesize \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"text":"Hello world","voice":"en-US-EmmaMultilingualNeural"}' \
|
||||||
|
--output speech.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customization
|
||||||
|
|
||||||
|
#### Update Icons
|
||||||
|
|
||||||
|
Replace `icon-192.png` and `icon-512.png` with your own icons.
|
||||||
|
|
||||||
|
For best results, create:
|
||||||
|
- 192x192 PNG for mobile devices
|
||||||
|
- 512x512 PNG for high-resolution displays
|
||||||
|
|
||||||
|
#### Update Theme Color
|
||||||
|
|
||||||
|
Edit the `--primary-color` variable in [styles.css](styles.css):
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--primary-color: #2563eb; /* Change this color */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update `theme_color` in [manifest.json](manifest.json).
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
### PWA Features
|
||||||
|
- ✅ Chrome/Edge (Desktop & Mobile)
|
||||||
|
- ✅ Safari (iOS 11.3+)
|
||||||
|
- ✅ Firefox (Desktop & Android)
|
||||||
|
- ✅ Samsung Internet
|
||||||
|
|
||||||
|
### Service Worker
|
||||||
|
- ✅ All modern browsers
|
||||||
|
- ❌ IE11 (not supported)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
If port 8000 is already in use:
|
||||||
|
```bash
|
||||||
|
python server.py --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Icons Not Showing
|
||||||
|
|
||||||
|
Make sure `icon-192.png` and `icon-512.png` exist in the web directory.
|
||||||
|
|
||||||
|
### Voices Not Loading
|
||||||
|
|
||||||
|
Check the server logs for errors. The server needs internet connection to fetch voices from Microsoft's API.
|
||||||
|
|
||||||
|
### CORS Issues
|
||||||
|
|
||||||
|
The server is configured to allow all origins for development. For production, update the CORS settings in [server.py](server.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["https://yourdomain.com"], # Update this
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Production Considerations
|
||||||
|
|
||||||
|
1. **Use a production ASGI server**: Uvicorn with multiple workers
|
||||||
|
```bash
|
||||||
|
uvicorn server:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use a reverse proxy**: nginx or Apache for SSL/TLS
|
||||||
|
|
||||||
|
3. **Set environment variables**:
|
||||||
|
```bash
|
||||||
|
export EDGE_TTS_HOST=0.0.0.0
|
||||||
|
export EDGE_TTS_PORT=8000
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Update CORS settings**: Restrict to your domain
|
||||||
|
|
||||||
|
5. **Enable HTTPS**: Required for PWA installation
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
Create a `Dockerfile`:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["python", "server.py", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and run:
|
||||||
|
```bash
|
||||||
|
docker build -t edge-tts-web .
|
||||||
|
docker run -p 8000:8000 edge-tts-web
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This web UI is built on top of [edge-tts](https://github.com/rany2/edge-tts).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit issues or pull requests.
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
- **edge-tts**: The underlying TTS library by [@rany2](https://github.com/rany2)
|
||||||
|
- **Microsoft Edge TTS**: The text-to-speech service
|
||||||
594
web/app.js
Normal file
594
web/app.js
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
// Configuration
|
||||||
|
const API_BASE_URL = window.location.hostname === 'localhost'
|
||||||
|
? 'http://localhost:8000/api'
|
||||||
|
: '/api';
|
||||||
|
|
||||||
|
// Sample sentences for different languages
|
||||||
|
const SAMPLE_SENTENCES = {
|
||||||
|
'ar': ['مرحبا، كيف حالك اليوم؟', 'الطقس جميل اليوم.', 'أتمنى لك يوما سعيدا.'],
|
||||||
|
'bg': ['Здравейте, как сте днес?', 'Времето е хубаво днес.', 'Желая ви приятен ден.'],
|
||||||
|
'ca': ['Hola, com estàs avui?', 'El temps és agradable avui.', 'Que tinguis un bon dia.'],
|
||||||
|
'cs': ['Ahoj, jak se máš dnes?', 'Počasí je dnes pěkné.', 'Přeji ti hezký den.'],
|
||||||
|
'da': ['Hej, hvordan har du det i dag?', 'Vejret er dejligt i dag.', 'Hav en god dag.'],
|
||||||
|
'de': ['Hallo, wie geht es dir heute?', 'Das Wetter ist heute schön.', 'Ich wünsche dir einen schönen Tag.'],
|
||||||
|
'el': ['Γεια σου, πώς είσαι σήμερα;', 'Ο καιρός είναι ωραίος σήμερα.', 'Σου εύχομαι μια όμορφη μέρα.'],
|
||||||
|
'en': ['Hello, how are you today?', 'The weather is nice today.', 'Have a wonderful day!'],
|
||||||
|
'es': ['Hola, ¿cómo estás hoy?', 'El clima está agradable hoy.', '¡Que tengas un buen día!'],
|
||||||
|
'fi': ['Hei, mitä kuuluu tänään?', 'Sää on kaunis tänään.', 'Mukavaa päivää!'],
|
||||||
|
'fr': ['Bonjour, comment allez-vous aujourd\'hui?', 'Le temps est agréable aujourd\'hui.', 'Passez une bonne journée!'],
|
||||||
|
'hi': ['नमस्ते, आज आप कैसे हैं?', 'आज मौसम अच्छा है।', 'आपका दिन शुभ हो।'],
|
||||||
|
'hr': ['Bok, kako si danas?', 'Vrijeme je lijepo danas.', 'Želim ti lijep dan.'],
|
||||||
|
'hu': ['Szia, hogy vagy ma?', 'Az idő szép ma.', 'Szép napot kívánok!'],
|
||||||
|
'id': ['Halo, apa kabar hari ini?', 'Cuacanya bagus hari ini.', 'Semoga harimu menyenangkan!'],
|
||||||
|
'it': ['Ciao, come stai oggi?', 'Il tempo è bello oggi.', 'Ti auguro una buona giornata!'],
|
||||||
|
'ja': ['こんにちは、今日はお元気ですか?', '今日は天気がいいですね。', '良い一日をお過ごしください。'],
|
||||||
|
'ko': ['안녕하세요, 오늘은 어떠세요?', '오늘 날씨가 좋네요.', '좋은 하루 보내세요!'],
|
||||||
|
'nl': ['Hallo, hoe gaat het vandaag?', 'Het weer is mooi vandaag.', 'Fijne dag gewenst!'],
|
||||||
|
'no': ['Hei, hvordan har du det i dag?', 'Været er fint i dag.', 'Ha en fin dag!'],
|
||||||
|
'pl': ['Cześć, jak się masz dzisiaj?', 'Pogoda jest ładna dzisiaj.', 'Miłego dnia!'],
|
||||||
|
'pt': ['Olá, como você está hoje?', 'O tempo está agradável hoje.', 'Tenha um ótimo dia!'],
|
||||||
|
'ro': ['Bună, ce mai faci astăzi?', 'Vremea este frumoasă astăzi.', 'O zi bună!'],
|
||||||
|
'ru': ['Привет, как дела сегодня?', 'Погода сегодня хорошая.', 'Хорошего дня!'],
|
||||||
|
'sk': ['Ahoj, ako sa máš dnes?', 'Počasie je dnes pekné.', 'Prajem ti pekný deň.'],
|
||||||
|
'sv': ['Hej, hur mår du idag?', 'Vädret är fint idag.', 'Ha en trevlig dag!'],
|
||||||
|
'th': ['สวัสดี วันนี้เป็นอย่างไรบ้าง?', 'อากาศดีวันนี้.', 'ขอให้มีความสุขตลอดวัน!'],
|
||||||
|
'tr': ['Merhaba, bugün nasılsın?', 'Hava bugün güzel.', 'İyi günler dilerim!'],
|
||||||
|
'uk': ['Привіт, як справи сьогодні?', 'Погода сьогодні гарна.', 'Гарного дня!'],
|
||||||
|
'vi': ['Xin chào, hôm nay bạn thế nào?', 'Thời tiết hôm nay đẹp.', 'Chúc bạn một ngày tốt lành!'],
|
||||||
|
'zh': ['你好,今天过得怎么样?', '今天天气真好。', '祝你有美好的一天!'],
|
||||||
|
// Cantonese (yue-CN)
|
||||||
|
'yue': ['你好,今日點呀?', '今日天氣好好。', '祝你有美好嘅一天!'],
|
||||||
|
// Wu Chinese (wuu-CN) - uses Simplified Chinese
|
||||||
|
'wuu': ['侬好,今朝好伐?', '今朝天气老好额。', '祝侬开心!'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// State
|
||||||
|
let voices = [];
|
||||||
|
let filteredVoices = [];
|
||||||
|
let currentAudioUrl = null;
|
||||||
|
let currentTestAudioUrl = null;
|
||||||
|
let history = [];
|
||||||
|
let deferredPrompt = null;
|
||||||
|
|
||||||
|
// DOM Elements
|
||||||
|
const textInput = document.getElementById('textInput');
|
||||||
|
const charCount = document.getElementById('charCount');
|
||||||
|
const voiceSelect = document.getElementById('voiceSelect');
|
||||||
|
const languageSelect = document.getElementById('languageSelect');
|
||||||
|
const genderFilter = document.getElementById('genderFilter');
|
||||||
|
const rateSlider = document.getElementById('rateSlider');
|
||||||
|
const rateValue = document.getElementById('rateValue');
|
||||||
|
const volumeSlider = document.getElementById('volumeSlider');
|
||||||
|
const volumeValue = document.getElementById('volumeValue');
|
||||||
|
const pitchSlider = document.getElementById('pitchSlider');
|
||||||
|
const pitchValue = document.getElementById('pitchValue');
|
||||||
|
const generateBtn = document.getElementById('generateBtn');
|
||||||
|
const clearBtn = document.getElementById('clearBtn');
|
||||||
|
const testVoiceBtn = document.getElementById('testVoiceBtn');
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const statusMessage = document.getElementById('statusMessage');
|
||||||
|
const audioSection = document.getElementById('audioSection');
|
||||||
|
const audioPlayer = document.getElementById('audioPlayer');
|
||||||
|
const downloadBtn = document.getElementById('downloadBtn');
|
||||||
|
const historyList = document.getElementById('historyList');
|
||||||
|
const onlineStatus = document.getElementById('onlineStatus');
|
||||||
|
const installPrompt = document.getElementById('installPrompt');
|
||||||
|
const installBtn = document.getElementById('installBtn');
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadVoices();
|
||||||
|
loadHistory();
|
||||||
|
setupEventListeners();
|
||||||
|
setupPWA();
|
||||||
|
updateOnlineStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
function setupEventListeners() {
|
||||||
|
textInput.addEventListener('input', updateCharCount);
|
||||||
|
|
||||||
|
languageSelect.addEventListener('change', () => {
|
||||||
|
filterVoices();
|
||||||
|
updateTestVoiceButton();
|
||||||
|
});
|
||||||
|
genderFilter.addEventListener('change', filterVoices);
|
||||||
|
voiceSelect.addEventListener('change', updateTestVoiceButton);
|
||||||
|
|
||||||
|
rateSlider.addEventListener('input', (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
rateValue.textContent = `${value >= 0 ? '+' : ''}${value}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
volumeSlider.addEventListener('input', (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
volumeValue.textContent = `${value >= 0 ? '+' : ''}${value}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
pitchSlider.addEventListener('input', (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
pitchValue.textContent = `${value >= 0 ? '+' : ''}${value}Hz`;
|
||||||
|
});
|
||||||
|
|
||||||
|
generateBtn.addEventListener('click', generateSpeech);
|
||||||
|
clearBtn.addEventListener('click', clearForm);
|
||||||
|
testVoiceBtn.addEventListener('click', testVoice);
|
||||||
|
downloadBtn.addEventListener('click', downloadAudio);
|
||||||
|
|
||||||
|
window.addEventListener('online', updateOnlineStatus);
|
||||||
|
window.addEventListener('offline', updateOnlineStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character count
|
||||||
|
function updateCharCount() {
|
||||||
|
const count = textInput.value.length;
|
||||||
|
charCount.textContent = count;
|
||||||
|
|
||||||
|
if (count > 4500) {
|
||||||
|
charCount.style.color = 'var(--error-color)';
|
||||||
|
} else if (count > 4000) {
|
||||||
|
charCount.style.color = 'var(--primary-color)';
|
||||||
|
} else {
|
||||||
|
charCount.style.color = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load voices from API
|
||||||
|
async function loadVoices() {
|
||||||
|
try {
|
||||||
|
// Add cache busting to ensure fresh data
|
||||||
|
const response = await fetch(`${API_BASE_URL}/voices?_=${Date.now()}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load voices');
|
||||||
|
|
||||||
|
voices = await response.json();
|
||||||
|
populateLanguageSelect();
|
||||||
|
|
||||||
|
showStatus('Voices loaded successfully', 'success');
|
||||||
|
console.log(`Loaded ${voices.length} voices from API`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading voices:', error);
|
||||||
|
showStatus('Failed to load voices. Please check the server connection.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate language select
|
||||||
|
function populateLanguageSelect() {
|
||||||
|
const languages = [...new Set(voices.map(v => v.Locale))].sort();
|
||||||
|
|
||||||
|
languageSelect.innerHTML = '<option value="">Select a language</option>';
|
||||||
|
languages.forEach(lang => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = lang;
|
||||||
|
option.textContent = getLanguageName(lang);
|
||||||
|
languageSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get language name from locale
|
||||||
|
function getLanguageName(locale) {
|
||||||
|
const names = voices.filter(v => v.Locale === locale);
|
||||||
|
return names.length > 0 ? names[0].LocaleName : locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter voices based on selected language and gender
|
||||||
|
function filterVoices() {
|
||||||
|
const selectedLanguage = languageSelect.value;
|
||||||
|
const selectedGender = genderFilter.value;
|
||||||
|
|
||||||
|
// If no language is selected, clear voice dropdown
|
||||||
|
if (!selectedLanguage) {
|
||||||
|
voiceSelect.innerHTML = '<option value="">Select language first</option>';
|
||||||
|
filteredVoices = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter voices by language and gender
|
||||||
|
filteredVoices = voices.filter(voice => {
|
||||||
|
const languageMatch = voice.Locale === selectedLanguage;
|
||||||
|
const genderMatch = !selectedGender || voice.Gender === selectedGender;
|
||||||
|
return languageMatch && genderMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
populateVoiceSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate voice select
|
||||||
|
function populateVoiceSelect() {
|
||||||
|
voiceSelect.innerHTML = '';
|
||||||
|
|
||||||
|
if (filteredVoices.length === 0) {
|
||||||
|
voiceSelect.innerHTML = '<option value="">No voices available for selected filters</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort voices alphabetically by LocalName
|
||||||
|
const sortedVoices = [...filteredVoices].sort((a, b) => {
|
||||||
|
return a.LocalName.localeCompare(b.LocalName);
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedVoices.forEach(voice => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = voice.Name;
|
||||||
|
option.textContent = `${voice.LocalName} (${voice.Gender})`;
|
||||||
|
voiceSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate speech
|
||||||
|
async function generateSpeech() {
|
||||||
|
const text = textInput.value.trim();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
showStatus('Please enter some text', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length > 5000) {
|
||||||
|
showStatus('Text exceeds maximum length of 5000 characters', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voice = voiceSelect.value;
|
||||||
|
if (!voice) {
|
||||||
|
showStatus('Please select a voice', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
text: text,
|
||||||
|
voice: voice,
|
||||||
|
rate: `${rateSlider.value >= 0 ? '+' : ''}${rateSlider.value}%`,
|
||||||
|
volume: `${volumeSlider.value >= 0 ? '+' : ''}${volumeSlider.value}%`,
|
||||||
|
pitch: `${pitchSlider.value >= 0 ? '+' : ''}${pitchSlider.value}Hz`
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
generateBtn.innerHTML = '<span class="loading"></span> Generating...';
|
||||||
|
progressBar.style.display = 'block';
|
||||||
|
hideStatus();
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/synthesize`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to generate speech' }));
|
||||||
|
const errorMsg = error.detail || 'Failed to generate speech';
|
||||||
|
|
||||||
|
// Check if it's an invalid voice error
|
||||||
|
if (errorMsg.includes('No audio') || errorMsg.includes('voice')) {
|
||||||
|
throw new Error(`Voice error: ${errorMsg}. Try selecting a different voice or refresh the page.`);
|
||||||
|
}
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Clean up previous audio URL
|
||||||
|
if (currentAudioUrl) {
|
||||||
|
URL.revokeObjectURL(currentAudioUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAudioUrl = URL.createObjectURL(blob);
|
||||||
|
audioPlayer.src = currentAudioUrl;
|
||||||
|
audioSection.style.display = 'block';
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
const selectedVoice = voices.find(v => v.Name === voice);
|
||||||
|
addToHistory({
|
||||||
|
text: text,
|
||||||
|
voice: voice,
|
||||||
|
voiceName: voiceSelect.options[voiceSelect.selectedIndex].text,
|
||||||
|
locale: selectedVoice ? selectedVoice.Locale : '',
|
||||||
|
localeName: selectedVoice ? selectedVoice.LocaleName : '',
|
||||||
|
params: params,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
showStatus('Speech generated successfully!', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating speech:', error);
|
||||||
|
showStatus(error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
generateBtn.innerHTML = '<span class="btn-icon">🎵</span> Generate Speech';
|
||||||
|
progressBar.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download audio
|
||||||
|
function downloadAudio() {
|
||||||
|
if (!currentAudioUrl) return;
|
||||||
|
|
||||||
|
const text = textInput.value.substring(0, 30).replace(/[^a-z0-9]/gi, '_');
|
||||||
|
const filename = `edge-tts-${text}-${Date.now()}.mp3`;
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = currentAudioUrl;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update test voice button state
|
||||||
|
function updateTestVoiceButton() {
|
||||||
|
const hasVoice = voiceSelect.value && voiceSelect.value !== '';
|
||||||
|
testVoiceBtn.disabled = !hasVoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sample sentence for language
|
||||||
|
function getSampleSentence(locale) {
|
||||||
|
// Extract language code (e.g., 'en' from 'en-US')
|
||||||
|
const langCode = locale.split('-')[0];
|
||||||
|
|
||||||
|
// Check for exact locale match first (for special cases like yue-CN, wuu-CN)
|
||||||
|
if (SAMPLE_SENTENCES[locale]) {
|
||||||
|
const sentences = SAMPLE_SENTENCES[locale];
|
||||||
|
return sentences[Math.floor(Math.random() * sentences.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check for language code match
|
||||||
|
if (SAMPLE_SENTENCES[langCode]) {
|
||||||
|
const sentences = SAMPLE_SENTENCES[langCode];
|
||||||
|
return sentences[Math.floor(Math.random() * sentences.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to English
|
||||||
|
const sentences = SAMPLE_SENTENCES['en'];
|
||||||
|
return sentences[Math.floor(Math.random() * sentences.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test voice with sample sentence
|
||||||
|
async function testVoice() {
|
||||||
|
const voice = voiceSelect.value;
|
||||||
|
const selectedLanguage = languageSelect.value;
|
||||||
|
|
||||||
|
if (!voice || !selectedLanguage) {
|
||||||
|
showStatus('Please select a voice first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sample sentence
|
||||||
|
const sampleText = getSampleSentence(selectedLanguage);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
text: sampleText,
|
||||||
|
voice: voice,
|
||||||
|
rate: `${rateSlider.value >= 0 ? '+' : ''}${rateSlider.value}%`,
|
||||||
|
volume: `${volumeSlider.value >= 0 ? '+' : ''}${volumeSlider.value}%`,
|
||||||
|
pitch: `${pitchSlider.value >= 0 ? '+' : ''}${pitchSlider.value}Hz`
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
testVoiceBtn.disabled = true;
|
||||||
|
testVoiceBtn.innerHTML = '<span class="loading"></span> Testing...';
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/synthesize`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to generate test speech' }));
|
||||||
|
const errorMsg = error.detail || 'Failed to generate test speech';
|
||||||
|
|
||||||
|
// Check if it's an invalid voice error
|
||||||
|
if (errorMsg.includes('No audio') || errorMsg.includes('voice')) {
|
||||||
|
throw new Error(`Voice error: ${errorMsg}. This voice may not be available. Try refreshing the page.`);
|
||||||
|
}
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Clean up previous test audio URL
|
||||||
|
if (currentTestAudioUrl) {
|
||||||
|
URL.revokeObjectURL(currentTestAudioUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTestAudioUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Play audio automatically
|
||||||
|
const testAudio = new Audio(currentTestAudioUrl);
|
||||||
|
testAudio.play();
|
||||||
|
|
||||||
|
showStatus(`Testing voice: "${sampleText}"`, 'info');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing voice:', error);
|
||||||
|
showStatus(error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
testVoiceBtn.disabled = false;
|
||||||
|
testVoiceBtn.innerHTML = '<span class="btn-icon">🎧</span> Test Voice';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear form
|
||||||
|
function clearForm() {
|
||||||
|
textInput.value = '';
|
||||||
|
updateCharCount();
|
||||||
|
rateSlider.value = 0;
|
||||||
|
volumeSlider.value = 0;
|
||||||
|
pitchSlider.value = 0;
|
||||||
|
rateValue.textContent = '+0%';
|
||||||
|
volumeValue.textContent = '+0%';
|
||||||
|
pitchValue.textContent = '+0Hz';
|
||||||
|
hideStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// History management
|
||||||
|
function loadHistory() {
|
||||||
|
const saved = localStorage.getItem('tts_history');
|
||||||
|
if (saved) {
|
||||||
|
history = JSON.parse(saved);
|
||||||
|
renderHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHistory() {
|
||||||
|
// Keep only last 10 items
|
||||||
|
history = history.slice(0, 10);
|
||||||
|
localStorage.setItem('tts_history', JSON.stringify(history));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToHistory(item) {
|
||||||
|
history.unshift(item);
|
||||||
|
saveHistory();
|
||||||
|
renderHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory() {
|
||||||
|
if (history.length === 0) {
|
||||||
|
historyList.innerHTML = '<p class="empty-state">No recent generations yet</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
historyList.innerHTML = '';
|
||||||
|
|
||||||
|
history.forEach((item, index) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'history-item';
|
||||||
|
|
||||||
|
const date = new Date(item.timestamp);
|
||||||
|
const timeAgo = getTimeAgo(date);
|
||||||
|
|
||||||
|
const languageInfo = item.localeName ? ` - ${item.localeName}` : '';
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="history-item-header">
|
||||||
|
<div class="history-item-text" title="${escapeHtml(item.text)}">
|
||||||
|
${escapeHtml(item.text)}
|
||||||
|
</div>
|
||||||
|
<div class="history-item-time">${timeAgo}</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-item-voice">${escapeHtml(item.voiceName)}${languageInfo}</div>
|
||||||
|
<div class="history-item-actions">
|
||||||
|
<button class="btn btn-primary" onclick="loadHistoryItem(${index})">
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="deleteHistoryItem(${index})">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
historyList.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHistoryItem(index) {
|
||||||
|
const item = history[index];
|
||||||
|
|
||||||
|
textInput.value = item.text;
|
||||||
|
updateCharCount();
|
||||||
|
|
||||||
|
// Find the voice in the voices list to get its locale
|
||||||
|
const voice = voices.find(v => v.Name === item.voice);
|
||||||
|
if (voice) {
|
||||||
|
// Set language first
|
||||||
|
languageSelect.value = voice.Locale;
|
||||||
|
// Trigger filter to populate voice dropdown
|
||||||
|
filterVoices();
|
||||||
|
// Then set the specific voice
|
||||||
|
voiceSelect.value = item.voice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set parameters
|
||||||
|
if (item.params) {
|
||||||
|
rateSlider.value = parseInt(item.params.rate);
|
||||||
|
volumeSlider.value = parseInt(item.params.volume);
|
||||||
|
pitchSlider.value = parseInt(item.params.pitch);
|
||||||
|
rateValue.textContent = item.params.rate;
|
||||||
|
volumeValue.textContent = item.params.volume;
|
||||||
|
pitchValue.textContent = item.params.pitch;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
showStatus('History item loaded', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteHistoryItem(index) {
|
||||||
|
history.splice(index, 1);
|
||||||
|
saveHistory();
|
||||||
|
renderHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
function showStatus(message, type = 'info') {
|
||||||
|
statusMessage.textContent = message;
|
||||||
|
statusMessage.className = `status-message ${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideStatus() {
|
||||||
|
statusMessage.className = 'status-message';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeAgo(date) {
|
||||||
|
const seconds = Math.floor((new Date() - date) / 1000);
|
||||||
|
|
||||||
|
if (seconds < 60) return 'Just now';
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||||
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||||
|
return `${Math.floor(seconds / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOnlineStatus() {
|
||||||
|
if (navigator.onLine) {
|
||||||
|
onlineStatus.textContent = '● Online';
|
||||||
|
onlineStatus.className = 'online';
|
||||||
|
} else {
|
||||||
|
onlineStatus.textContent = '● Offline';
|
||||||
|
onlineStatus.className = 'offline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PWA Setup
|
||||||
|
function setupPWA() {
|
||||||
|
// Register service worker
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('sw.js')
|
||||||
|
.then(registration => {
|
||||||
|
console.log('Service Worker registered:', registration);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Service Worker registration failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install prompt
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPrompt = e;
|
||||||
|
installPrompt.style.display = 'inline';
|
||||||
|
});
|
||||||
|
|
||||||
|
installBtn.addEventListener('click', async () => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
|
||||||
|
console.log(`User response to install prompt: ${outcome}`);
|
||||||
|
deferredPrompt = null;
|
||||||
|
installPrompt.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
console.log('PWA installed');
|
||||||
|
installPrompt.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
40
web/build.sh
Executable file
40
web/build.sh
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build script for Edge TTS Web UI Docker image
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
echo "🏗️ Building Edge TTS Web UI Docker Image"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
IMAGE_NAME="edge-tts-web"
|
||||||
|
IMAGE_TAG="${1:-latest}"
|
||||||
|
FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
|
||||||
|
# Check if Docker is installed
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ Docker is not installed. Please install Docker first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the Docker image
|
||||||
|
echo "📦 Building Docker image: ${FULL_IMAGE_NAME}"
|
||||||
|
docker build -t "${FULL_IMAGE_NAME}" .
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ Build successful!"
|
||||||
|
echo ""
|
||||||
|
echo "Image details:"
|
||||||
|
docker images | grep "${IMAGE_NAME}" | head -n 1
|
||||||
|
echo ""
|
||||||
|
echo "To run the container:"
|
||||||
|
echo " docker run -d -p 8000:8000 --name edge-tts ${FULL_IMAGE_NAME}"
|
||||||
|
echo ""
|
||||||
|
echo "Or use docker-compose:"
|
||||||
|
echo " docker-compose up -d"
|
||||||
|
else
|
||||||
|
echo "❌ Build failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
113
web/deploy.sh
Executable file
113
web/deploy.sh
Executable file
@@ -0,0 +1,113 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deployment script for Edge TTS Web UI to remote server
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
echo "🚀 Edge TTS Web UI - Remote Deployment Script"
|
||||||
|
echo "=============================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Configuration - Edit these values for your server
|
||||||
|
REMOTE_USER="${REMOTE_USER:-root}"
|
||||||
|
REMOTE_HOST="${REMOTE_HOST:-your-server.com}"
|
||||||
|
REMOTE_PATH="${REMOTE_PATH:-/opt/edge-tts}"
|
||||||
|
IMAGE_NAME="edge-tts-web"
|
||||||
|
IMAGE_TAG="${1:-latest}"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_info() {
|
||||||
|
echo -e "${GREEN}ℹ️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}❌ $1${NC}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if required variables are set
|
||||||
|
if [ "$REMOTE_HOST" = "your-server.com" ]; then
|
||||||
|
print_warning "Please configure REMOTE_HOST before deployment"
|
||||||
|
echo ""
|
||||||
|
echo "Usage:"
|
||||||
|
echo " REMOTE_HOST=192.168.1.100 ./deploy.sh"
|
||||||
|
echo " REMOTE_HOST=myserver.com REMOTE_USER=deployer ./deploy.sh"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if SSH key is available
|
||||||
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "${REMOTE_USER}@${REMOTE_HOST}" exit 2>/dev/null; then
|
||||||
|
print_warning "SSH key authentication not configured or server unreachable"
|
||||||
|
print_info "You may be prompted for password multiple times"
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_info "Deployment Configuration:"
|
||||||
|
echo " Remote Host: ${REMOTE_USER}@${REMOTE_HOST}"
|
||||||
|
echo " Remote Path: ${REMOTE_PATH}"
|
||||||
|
echo " Image: ${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Continue with deployment? (y/n) " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Deployment cancelled"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 1: Create remote directory
|
||||||
|
print_info "Creating remote directory..."
|
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p ${REMOTE_PATH}"
|
||||||
|
|
||||||
|
# Step 2: Copy files to remote server
|
||||||
|
print_info "Copying files to remote server..."
|
||||||
|
rsync -avz --progress \
|
||||||
|
--exclude='*.pyc' \
|
||||||
|
--exclude='__pycache__' \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='venv' \
|
||||||
|
--exclude='.venv' \
|
||||||
|
--exclude='*.mp3' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
./ "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"
|
||||||
|
|
||||||
|
# Step 3: Build and start containers on remote server
|
||||||
|
print_info "Building and starting containers on remote server..."
|
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" << EOF
|
||||||
|
cd ${REMOTE_PATH}
|
||||||
|
|
||||||
|
# Stop existing containers
|
||||||
|
docker-compose down 2>/dev/null || true
|
||||||
|
|
||||||
|
# Build new image
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# Start containers
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Show status
|
||||||
|
echo ""
|
||||||
|
echo "Container status:"
|
||||||
|
docker-compose ps
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
print_info "Deployment successful! ✅"
|
||||||
|
echo ""
|
||||||
|
echo "Access your application at:"
|
||||||
|
echo " http://${REMOTE_HOST}:8000"
|
||||||
|
echo ""
|
||||||
|
echo "To check logs:"
|
||||||
|
echo " ssh ${REMOTE_USER}@${REMOTE_HOST} 'cd ${REMOTE_PATH} && docker-compose logs -f'"
|
||||||
|
else
|
||||||
|
print_error "Deployment failed!"
|
||||||
|
fi
|
||||||
25
web/docker-compose.yml
Normal file
25
web/docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
edge-tts-web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: edge-tts-web
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- edge-tts-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
edge-tts-network:
|
||||||
|
driver: bridge
|
||||||
BIN
web/icon-192.png
Normal file
BIN
web/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
web/icon-512.png
Normal file
BIN
web/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
4
web/icon.svg
Normal file
4
web/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="192" height="192" fill="#2563eb"/>
|
||||||
|
<text x="50%" y="50%" font-size="100" fill="white" text-anchor="middle" dominant-baseline="middle">🎙️</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 238 B |
175
web/index.html
Normal file
175
web/index.html
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Edge TTS - Text to Speech PWA using Microsoft Edge's online TTS service">
|
||||||
|
<meta name="theme-color" content="#2563eb">
|
||||||
|
<title>Edge TTS - Text to Speech</title>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="icon" type="image/png" href="icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="icon-192.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🎙️ Edge TTS</h1>
|
||||||
|
<p class="subtitle">Text to Speech Converter</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Text Input Section -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>Enter Text</h2>
|
||||||
|
<textarea
|
||||||
|
id="textInput"
|
||||||
|
placeholder="Enter text to convert to speech..."
|
||||||
|
rows="6"
|
||||||
|
maxlength="5000"
|
||||||
|
></textarea>
|
||||||
|
<div class="char-count">
|
||||||
|
<span id="charCount">0</span> / 5000 characters
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Voice Selection Section -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>Voice Settings</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="languageSelect">Language</label>
|
||||||
|
<select id="languageSelect">
|
||||||
|
<option value="">Loading languages...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="genderFilter">Gender</label>
|
||||||
|
<select id="genderFilter">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="Female">Female</option>
|
||||||
|
<option value="Male">Male</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="voiceSelect">Voice</label>
|
||||||
|
<select id="voiceSelect">
|
||||||
|
<option value="">Select language first</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-voice-section">
|
||||||
|
<button id="testVoiceBtn" class="btn btn-secondary" disabled>
|
||||||
|
<span class="btn-icon">🎧</span>
|
||||||
|
Test Voice
|
||||||
|
</button>
|
||||||
|
<p class="test-voice-hint">Select a voice to test it with a sample sentence</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prosody Controls -->
|
||||||
|
<div class="prosody-controls">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rateSlider">
|
||||||
|
Speed: <span id="rateValue">+0%</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="rateSlider"
|
||||||
|
min="-100"
|
||||||
|
max="100"
|
||||||
|
value="0"
|
||||||
|
step="10"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="volumeSlider">
|
||||||
|
Volume: <span id="volumeValue">+0%</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="volumeSlider"
|
||||||
|
min="-100"
|
||||||
|
max="100"
|
||||||
|
value="0"
|
||||||
|
step="10"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pitchSlider">
|
||||||
|
Pitch: <span id="pitchValue">+0Hz</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="pitchSlider"
|
||||||
|
min="-500"
|
||||||
|
max="500"
|
||||||
|
value="0"
|
||||||
|
step="50"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Actions Section -->
|
||||||
|
<section class="card">
|
||||||
|
<div class="actions">
|
||||||
|
<button id="generateBtn" class="btn btn-primary">
|
||||||
|
<span class="btn-icon">🎵</span>
|
||||||
|
Generate Speech
|
||||||
|
</button>
|
||||||
|
<button id="clearBtn" class="btn btn-secondary">
|
||||||
|
<span class="btn-icon">🗑️</span>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="progressBar" class="progress-bar" style="display: none;">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="statusMessage" class="status-message"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Audio Player Section -->
|
||||||
|
<section id="audioSection" class="card" style="display: none;">
|
||||||
|
<h2>Audio Output</h2>
|
||||||
|
<audio id="audioPlayer" controls></audio>
|
||||||
|
<div class="audio-actions">
|
||||||
|
<button id="downloadBtn" class="btn btn-primary">
|
||||||
|
<span class="btn-icon">⬇️</span>
|
||||||
|
Download MP3
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- History Section -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>Recent Generations</h2>
|
||||||
|
<div id="historyList" class="history-list">
|
||||||
|
<p class="empty-state">No recent generations yet</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>
|
||||||
|
Powered by <a href="https://github.com/rany2/edge-tts" target="_blank">edge-tts</a>
|
||||||
|
| <span id="installPrompt" style="display: none;">
|
||||||
|
<button id="installBtn" class="btn-link">Install App</button>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="status-indicator">
|
||||||
|
<span id="onlineStatus" class="online">● Online</span>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
web/manifest.json
Normal file
40
web/manifest.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "Edge TTS",
|
||||||
|
"short_name": "Edge TTS",
|
||||||
|
"description": "Convert text to speech using Microsoft Edge's online TTS service",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#2563eb",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["productivity", "utilities"],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "screenshot.png",
|
||||||
|
"sizes": "1280x720",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"share_target": {
|
||||||
|
"action": "/",
|
||||||
|
"method": "GET",
|
||||||
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
web/requirements.txt
Normal file
4
web/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.109.0
|
||||||
|
uvicorn[standard]>=0.27.0
|
||||||
|
pydantic>=2.5.0
|
||||||
|
edge-tts>=7.0.0
|
||||||
256
web/server.py
Executable file
256
web/server.py
Executable file
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Edge TTS Web API Server
|
||||||
|
|
||||||
|
This server provides a REST API for the edge-tts web UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Response
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# Import edge_tts
|
||||||
|
import edge_tts
|
||||||
|
from edge_tts import VoicesManager
|
||||||
|
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title="Edge TTS API",
|
||||||
|
description="REST API for Microsoft Edge Text-to-Speech service",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Global voices cache
|
||||||
|
voices_cache: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Models
|
||||||
|
class SynthesizeRequest(BaseModel):
|
||||||
|
text: str = Field(..., max_length=5000, description="Text to convert to speech")
|
||||||
|
voice: str = Field(default="en-US-EmmaMultilingualNeural", description="Voice name")
|
||||||
|
rate: str = Field(default="+0%", description="Speech rate (e.g., '+0%', '-50%', '+100%')")
|
||||||
|
volume: str = Field(default="+0%", description="Volume (e.g., '+0%', '-50%', '+100%')")
|
||||||
|
pitch: str = Field(default="+0Hz", description="Pitch (e.g., '+0Hz', '-500Hz', '+500Hz')")
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceResponse(BaseModel):
|
||||||
|
Name: str
|
||||||
|
ShortName: str
|
||||||
|
Gender: str
|
||||||
|
Locale: str
|
||||||
|
LocaleName: str
|
||||||
|
LocalName: Optional[str] = None
|
||||||
|
DisplayName: Optional[str] = None
|
||||||
|
Status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# API Routes
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Serve the main web page"""
|
||||||
|
return FileResponse("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {"status": "healthy", "service": "edge-tts-api"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/voices")
|
||||||
|
async def get_voices():
|
||||||
|
"""
|
||||||
|
Get list of all available voices.
|
||||||
|
|
||||||
|
Returns a list of voice objects with their properties.
|
||||||
|
"""
|
||||||
|
global voices_cache
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use cached voices if available
|
||||||
|
if voices_cache is None:
|
||||||
|
logger.info("Fetching voices from Edge TTS service...")
|
||||||
|
voices_cache = await edge_tts.list_voices()
|
||||||
|
logger.info(f"Loaded {len(voices_cache)} voices")
|
||||||
|
|
||||||
|
return voices_cache
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching voices: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to fetch voices: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/synthesize")
|
||||||
|
async def synthesize_speech(request: SynthesizeRequest):
|
||||||
|
"""
|
||||||
|
Synthesize speech from text.
|
||||||
|
|
||||||
|
Returns an MP3 audio file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Synthesizing speech: text_length={len(request.text)}, voice={request.voice}")
|
||||||
|
|
||||||
|
# Validate text
|
||||||
|
if not request.text.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
||||||
|
|
||||||
|
if len(request.text) > 5000:
|
||||||
|
raise HTTPException(status_code=400, detail="Text exceeds maximum length of 5000 characters")
|
||||||
|
|
||||||
|
# Create Communicate instance
|
||||||
|
communicate = edge_tts.Communicate(
|
||||||
|
text=request.text,
|
||||||
|
voice=request.voice,
|
||||||
|
rate=request.rate,
|
||||||
|
volume=request.volume,
|
||||||
|
pitch=request.pitch
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate audio
|
||||||
|
audio_data = io.BytesIO()
|
||||||
|
|
||||||
|
async for chunk in communicate.stream():
|
||||||
|
if chunk["type"] == "audio":
|
||||||
|
audio_data.write(chunk["data"])
|
||||||
|
|
||||||
|
# Check if audio was generated
|
||||||
|
audio_data.seek(0)
|
||||||
|
if audio_data.getbuffer().nbytes == 0:
|
||||||
|
raise HTTPException(status_code=500, detail="No audio was generated")
|
||||||
|
|
||||||
|
logger.info(f"Successfully generated {audio_data.getbuffer().nbytes} bytes of audio")
|
||||||
|
|
||||||
|
# Return audio as MP3
|
||||||
|
return Response(
|
||||||
|
content=audio_data.getvalue(),
|
||||||
|
media_type="audio/mpeg",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": "attachment; filename=speech.mp3"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except edge_tts.exceptions.NoAudioReceived as e:
|
||||||
|
logger.error(f"No audio received: {e}")
|
||||||
|
raise HTTPException(status_code=400, detail="No audio was generated. Check your parameters.")
|
||||||
|
|
||||||
|
except edge_tts.exceptions.UnknownResponse as e:
|
||||||
|
logger.error(f"Unknown response from TTS service: {e}")
|
||||||
|
raise HTTPException(status_code=502, detail="Unknown response from TTS service")
|
||||||
|
|
||||||
|
except edge_tts.exceptions.WebSocketError as e:
|
||||||
|
logger.error(f"WebSocket error: {e}")
|
||||||
|
raise HTTPException(status_code=503, detail="Failed to connect to TTS service")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error synthesizing speech: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to synthesize speech: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/synthesize-with-subtitles")
|
||||||
|
async def synthesize_with_subtitles(request: SynthesizeRequest):
|
||||||
|
"""
|
||||||
|
Synthesize speech from text and generate subtitles.
|
||||||
|
|
||||||
|
Returns JSON with audio data (base64) and SRT subtitles.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Synthesizing with subtitles: text_length={len(request.text)}, voice={request.voice}")
|
||||||
|
|
||||||
|
# Validate text
|
||||||
|
if not request.text.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
||||||
|
|
||||||
|
# Create Communicate instance
|
||||||
|
communicate = edge_tts.Communicate(
|
||||||
|
text=request.text,
|
||||||
|
voice=request.voice,
|
||||||
|
rate=request.rate,
|
||||||
|
volume=request.volume,
|
||||||
|
pitch=request.pitch
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create subtitle maker
|
||||||
|
submaker = edge_tts.SubMaker()
|
||||||
|
|
||||||
|
# Generate audio and subtitles
|
||||||
|
audio_data = io.BytesIO()
|
||||||
|
|
||||||
|
async for chunk in communicate.stream():
|
||||||
|
if chunk["type"] == "audio":
|
||||||
|
audio_data.write(chunk["data"])
|
||||||
|
elif chunk["type"] in ("WordBoundary", "SentenceBoundary"):
|
||||||
|
submaker.feed(chunk)
|
||||||
|
|
||||||
|
# Get subtitles
|
||||||
|
subtitles = submaker.get_srt()
|
||||||
|
|
||||||
|
# Return both audio and subtitles
|
||||||
|
import base64
|
||||||
|
audio_data.seek(0)
|
||||||
|
audio_base64 = base64.b64encode(audio_data.read()).decode('utf-8')
|
||||||
|
|
||||||
|
return {
|
||||||
|
"audio": audio_base64,
|
||||||
|
"subtitles": subtitles,
|
||||||
|
"format": "mp3"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error synthesizing with subtitles: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to synthesize: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# Mount static files
|
||||||
|
app.mount("/", StaticFiles(directory=".", html=True), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run the server"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Edge TTS Web API Server")
|
||||||
|
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
|
||||||
|
parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
|
||||||
|
parser.add_argument("--reload", action="store_true", help="Enable auto-reload")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logger.info(f"Starting Edge TTS Web Server on {args.host}:{args.port}")
|
||||||
|
logger.info(f"Visit http://localhost:{args.port} to use the web interface")
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"server:app",
|
||||||
|
host=args.host,
|
||||||
|
port=args.port,
|
||||||
|
reload=args.reload,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
28
web/start.sh
Executable file
28
web/start.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Quick start script for Edge TTS Web UI
|
||||||
|
|
||||||
|
echo "🎙️ Starting Edge TTS Web UI..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
source ../.venv/bin/activate
|
||||||
|
|
||||||
|
# Check if Python is installed
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
echo "❌ Python 3 is not installed. Please install Python 3.8 or higher."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if dependencies are installed
|
||||||
|
if ! python3 -c "import fastapi" 2>/dev/null; then
|
||||||
|
echo "📦 Installing dependencies..."
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
echo "✅ Starting server on http://localhost:8000"
|
||||||
|
echo ""
|
||||||
|
echo "Press Ctrl+C to stop the server"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
python3 server.py
|
||||||
488
web/styles.css
Normal file
488
web/styles.css
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #2563eb;
|
||||||
|
--primary-hover: #1d4ed8;
|
||||||
|
--secondary-color: #64748b;
|
||||||
|
--background: #f8fafc;
|
||||||
|
--card-background: #ffffff;
|
||||||
|
--text-primary: #1e293b;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--success-color: #10b981;
|
||||||
|
--error-color: #ef4444;
|
||||||
|
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.card {
|
||||||
|
background: var(--card-background);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Elements */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test Voice Section */
|
||||||
|
.test-voice-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-voice-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prosody Controls */
|
||||||
|
.prosody-controls {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--border-color);
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #475569;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
animation: progress 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress {
|
||||||
|
0% {
|
||||||
|
width: 0%;
|
||||||
|
margin-left: 0%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
width: 50%;
|
||||||
|
margin-left: 25%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
width: 0%;
|
||||||
|
margin-left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Message */
|
||||||
|
.status-message {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message.success {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message.error {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message.info {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio Section */
|
||||||
|
#audioPlayer {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History */
|
||||||
|
.history-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-voice {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-actions button {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-voice-section {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-voice-section .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-voice-hint {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Install prompt animation */
|
||||||
|
#installPrompt {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading animation */
|
||||||
|
.loading {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-top-color: var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
web/sw.js
Normal file
142
web/sw.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// Service Worker for Edge TTS PWA
|
||||||
|
const CACHE_NAME = 'edge-tts-v1';
|
||||||
|
const urlsToCache = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/styles.css',
|
||||||
|
'/app.js',
|
||||||
|
'/manifest.json',
|
||||||
|
'/icon-192.png',
|
||||||
|
'/icon-512.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event - cache resources
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then((cache) => {
|
||||||
|
console.log('Opened cache');
|
||||||
|
return cache.addAll(urlsToCache.map(url => {
|
||||||
|
// Try to add each URL, but don't fail if some are missing
|
||||||
|
return cache.add(url).catch(err => {
|
||||||
|
console.log('Failed to cache:', url, err);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event - clean up old caches
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map((cacheName) => {
|
||||||
|
if (cacheName !== CACHE_NAME) {
|
||||||
|
console.log('Deleting old cache:', cacheName);
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event - serve from cache, fallback to network
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
|
||||||
|
// Skip API requests - always go to network
|
||||||
|
if (request.url.includes('/api/')) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.catch(() => {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Network unavailable' }),
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first strategy for static assets
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request)
|
||||||
|
.then((response) => {
|
||||||
|
// Cache hit - return response
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the request
|
||||||
|
const fetchRequest = request.clone();
|
||||||
|
|
||||||
|
return fetch(fetchRequest).then((response) => {
|
||||||
|
// Check if valid response
|
||||||
|
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the response
|
||||||
|
const responseToCache = response.clone();
|
||||||
|
|
||||||
|
// Cache the new resource
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then((cache) => {
|
||||||
|
cache.put(request, responseToCache);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Return offline page or error
|
||||||
|
return new Response('Offline', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable'
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background sync for offline TTS generation (future enhancement)
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
if (event.tag === 'sync-tts') {
|
||||||
|
event.waitUntil(syncTTS());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function syncTTS() {
|
||||||
|
// Placeholder for future offline TTS queue functionality
|
||||||
|
console.log('Syncing pending TTS requests...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push notifications (future enhancement)
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
const data = event.data.json();
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/icon-192.png',
|
||||||
|
vibrate: [200, 100, 200]
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification click handler
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.openWindow('/')
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user