Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
customtkinter
edge-tts>=7.2.3
piper-tts>=1.2.0
langid>=1.1.6
sounddevice
soundfile>=0.12.0
Expand Down
15 changes: 14 additions & 1 deletion src/config/settings_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ class SettingsManager:
"visible_buttons": ["speak", "stop", "clear", "voice", "overlay"], # Toggleable buttons to show (settings is always visible)

# Overlay Settings
"overlay_visible": True # Whether the recording overlay is visible
"overlay_visible": True, # Whether the recording overlay is visible

# TTS Provider Selection
"tts_provider": "edge", # "edge" (Edge TTS, online) or "piper" (Piper TTS, offline)
}


Expand Down Expand Up @@ -315,6 +318,11 @@ def set(self, key, value):
logger.warning("Invalid appearance mode: %s, using default", value)
value = self.DEFAULT_SETTINGS["appearance_mode"]

# Validate TTS provider
if key == "tts_provider" and value not in ["edge", "piper"]:
logger.warning("Invalid TTS provider: %s, using default", value)
value = self.DEFAULT_SETTINGS["tts_provider"]

self._settings[key] = value

def get_all(self):
Expand Down Expand Up @@ -378,6 +386,11 @@ def validate_settings(self) -> list:
if appearance not in ["Dark", "Light", "System"]:
issues.append(f"Invalid appearance mode: {appearance}")

# Check TTS provider
tts_provider = self._settings.get("tts_provider")
if tts_provider not in ["edge", "piper"]:
issues.append(f"Invalid TTS provider: {tts_provider}")

# Check numeric range settings
rate = self._settings.get("rate")
if not isinstance(rate, (int, float)) or not (-100 <= rate <= 100):
Expand Down
2 changes: 2 additions & 0 deletions src/gui/settings_tabs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from .behavior_tab import BehaviorTab
from .vrchat_osc_tab import VRChatOSCTab
from .advanced_tab import AdvancedTab
from .tts_provider_tab import TTSProviderTab

__all__ = [
'BaseTab',
Expand All @@ -45,4 +46,5 @@
'BehaviorTab',
'VRChatOSCTab',
'AdvancedTab',
'TTSProviderTab',
]
148 changes: 148 additions & 0 deletions src/gui/settings_tabs/tts_provider_tab.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""
TTS Provider Tab
Settings for choosing between available TTS providers (online / offline).
"""
import customtkinter as ctk
from typing import Any, List, Dict

from .base_tab import BaseTab
from ..theme_constants import (
FONT_SM, FONT_MD, FONT_LG, FONT_XL, FONT_WEIGHT_BOLD,
COLOR_INFO,
)


_PROVIDER_OPTIONS = [
"edge",
"piper",
]

_PROVIDER_LABELS = {
"edge": "Edge TTS (Online)",
"piper": "Piper TTS (Offline)",
}

_PROVIDER_DESCRIPTIONS = {
"edge": (
"Microsoft Edge Text-to-Speech\n\n"
"• Requires an active internet connection\n"
"• 100+ high-quality neural voices\n"
"• Broad language and dialect support\n"
"• Rate, volume, and pitch controls"
),
"piper": (
"Piper — Open-Source Offline Neural TTS\n\n"
"• Works fully offline — no internet needed\n"
"• Privacy-focused (no data sent externally)\n"
"• Open-source voice models (ONNX-based)\n"
"• Voice models are downloaded on first use\n"
"• Rate and volume controls supported\n"
"• Note: pitch control is not supported by Piper"
),
}


class TTSProviderTab(BaseTab):
"""Tab for selecting the active TTS provider."""

def _create_content(self):
"""Build the TTS Provider tab UI."""
self.setup_layout()

# Title
ctk.CTkLabel(
self.scroll,
text="TTS Provider",
font=ctk.CTkFont(size=FONT_XL, weight=FONT_WEIGHT_BOLD),
).pack(anchor="w", pady=(10, 5))

intro_label = ctk.CTkLabel(
self.scroll,
text=(
"Choose which text-to-speech engine CriTTS uses. "
"Changes take effect after saving and will also update the available voice list."
),
font=ctk.CTkFont(size=FONT_SM),
text_color="gray",
wraplength=550,
)
intro_label.pack(anchor="w", pady=(0, 15))
self.add_wraplength_label(intro_label)

# --- Provider selection ---
self.create_section_header("Active Provider").pack(anchor="w", pady=(5, 5))

current = self.settings.get("tts_provider", "edge")

self._provider_var = ctk.StringVar(value=_PROVIDER_LABELS.get(current, _PROVIDER_LABELS["edge"]))

self._provider_dropdown = ctk.CTkComboBox(
self.scroll,
variable=self._provider_var,
values=[_PROVIDER_LABELS[k] for k in _PROVIDER_OPTIONS],
font=ctk.CTkFont(size=FONT_MD),
state="readonly",
width=320,
command=self._on_provider_changed,
)
self._provider_dropdown.pack(anchor="w", pady=(5, 10))

self.create_separator(self.scroll).pack(fill="x", pady=(5, 10))

# --- Description box ---
self.create_section_header("Provider Details").pack(anchor="w", pady=(5, 5))

self._desc_label = ctk.CTkLabel(
self.scroll,
text=_PROVIDER_DESCRIPTIONS.get(current, ""),
font=ctk.CTkFont(size=FONT_SM),
justify="left",
wraplength=500,
)
self._desc_label.pack(anchor="w", pady=(5, 10))
self.add_wraplength_label(self._desc_label)

self.create_separator(self.scroll).pack(fill="x", pady=(5, 10))

# --- Informational note about voices ---
note_label = ctk.CTkLabel(
self.scroll,
text=(
"ℹ After switching providers, save and reopen Settings to see "
"the updated voice list in the Voice tab."
),
font=ctk.CTkFont(size=FONT_SM),
text_color=COLOR_INFO,
wraplength=500,
justify="left",
)
note_label.pack(anchor="w", pady=(0, 10))
self.add_wraplength_label(note_label)

# ------------------------------------------------------------------

def _on_provider_changed(self, selected_label: str):
"""Update the description box when a new provider is selected."""
key = self._label_to_key(selected_label)
self._desc_label.configure(text=_PROVIDER_DESCRIPTIONS.get(key, ""))

@staticmethod
def _label_to_key(label: str) -> str:
"""Reverse-lookup: human-readable label → internal key."""
for k, v in _PROVIDER_LABELS.items():
if v == label:
return k
return "edge"

# ------------------------------------------------------------------
# BaseTab interface
# ------------------------------------------------------------------

def get_settings(self) -> Dict[str, Any]:
return {"tts_provider": self._label_to_key(self._provider_var.get())}

def validate(self) -> List[str]:
key = self._label_to_key(self._provider_var.get())
if key not in _PROVIDER_OPTIONS:
return [f"Unknown TTS provider: '{self._provider_var.get()}'"]
return []
9 changes: 7 additions & 2 deletions src/gui/settings_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from .keybind_manager import KeybindManager
from .settings_tabs import (
VoiceTab, AudioOutputTab, AppearanceTab, AbbreviationsTab,
KeybindsTab, BehaviorTab, VRChatOSCTab, AdvancedTab
KeybindsTab, BehaviorTab, VRChatOSCTab, AdvancedTab, TTSProviderTab
)
from .utils.scroll_utils import prevent_scroll_propagation
from .theme_constants import (
Expand Down Expand Up @@ -133,7 +133,7 @@ def _create_window(self):

# Voice Settings Tab
voice_frame = self.tabview.add("Voice")
self.voice_tab_obj = VoiceTab(voice_frame, self.settings, self.tts_engine, self.audio_router, self._on_change_placeholder)
self.voice_tab_obj = VoiceTab(voice_frame, self.settings, self.tts_engine, self.audio_router, self._on_change_placeholder, parent_window=self.window)
self.tabs.append(self.voice_tab_obj)

# Audio Output Tab
Expand Down Expand Up @@ -171,6 +171,11 @@ def _create_window(self):
self.advanced_tab_obj = AdvancedTab(advanced_frame, self.settings, self.tts_engine, self.audio_router, self._on_change_placeholder)
self.tabs.append(self.advanced_tab_obj)

# TTS Provider Tab
provider_frame = self.tabview.add("TTS Provider")
self.provider_tab_obj = TTSProviderTab(provider_frame, self.settings, self.tts_engine, self.audio_router, self._on_change_placeholder)
self.tabs.append(self.provider_tab_obj)

# Buttons frame - fixed at bottom with standardized padding
self.buttons_frame = ctk.CTkFrame(self.window, fg_color="transparent")
self.buttons_frame.grid(row=1, column=0, padx=0, pady=(SPACING_SM, SPACING_SM), sticky="ew")
Expand Down
10 changes: 8 additions & 2 deletions src/tts/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
TTS Provider Abstraction Layer

This module defines the abstract base class for TTS providers and provides
a common interface for TTS services like Edge TTS.
a common interface for TTS services like Edge TTS and Piper TTS.
"""

from abc import ABC, abstractmethod
from typing import Dict, List, Any
from typing import Dict, List, Any, Optional


class TTSProvider(ABC):
Expand Down Expand Up @@ -54,4 +54,10 @@ def clear_cache(self) -> None:
"""Clear any cached data (voices, etc.)."""
pass

def get_default_voice(self) -> Optional[str]:
"""Return the default voice identifier for this provider.

Override in subclasses to provide a sensible provider-specific default.
Returns None if no default is defined.
"""
return None
4 changes: 4 additions & 0 deletions src/tts/providers/edge_tts_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,7 @@ def clear_cache(self) -> None:
"""Clear the voice cache."""
self._voice_cache = None
self._cache_time = 0

def get_default_voice(self) -> str:
"""Return the default Edge TTS voice."""
return "en-US-AriaNeural"
Loading