diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0c7befb..cd7e6fa 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,7 +31,7 @@ jobs:
uses: astral-sh/setup-uv@v7
- name: Sync dependencies
- run: uv sync --dev
+ run: uv sync --dev --all-extras
- name: Verify formatting
run: uv run ruff format --check .
diff --git a/AGENTS.md b/AGENTS.md
index a2faa1d..f4da79b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,15 +73,15 @@ Do not modify the execution environment to work around failures.
Before CI, format the repository with `uv run ruff format .`.
-Expected CI runtime is 3-5 minutes.
+Expected CI runtime is around 5 minutes.
If CI runtime exceeds 5 minutes:
- Assume it may have stalled.
- Stop it from running any further.
- Report that the CI has taken too long.
- Do not try to extend any timeouts.
-- Do not try to work around the problem.
-- Wait for any further guidance.
+- Attempt to run again only the relevant CI steps in isolation.
+- If the isolated CI attempts also fail or time out, report it back.
After each task, run `scripts/update_docstrings.py` and then replace placeholders in docstrings like:
@@ -100,15 +100,25 @@ Returns:
with proper documentation, while preserving the docstring format.
+If this process updates typing or dataclass related docstrings, remove the placeholders instead of completing them.
+
This process may leave some formatting inaccuracies, run `uv run ruff format .` again after completing docstrings.
-If CI fails or times out, report the actual failure clearly. Do not claim success.
+## Localization
+
+Celune does not use hardcoded strings in English. Define each new string you add into Celune's localization string database.
+
+Do not use raw string literals in the code. Always use `string("key_name", **kwargs)` in string literals to populate them from the global localization string database.
+
+If you find any raw strings in the code, add them to the localization string database, and remove the hardcoded string.
+
+Make sure to only modify user-facing strings (both normal and dev mode strings), don't change anything internal.
## Python and Environment
* Supported Python versions are 3.12 and 3.13.
* Use `uv` for environment management.
-* Do not use `pip` directly unless explicitly required.
+* Do not use `pip` directly unless explicitly required. If you need to run `pip` alone, do it so with `uv pip` instead.
* Do not assume CPU-only mode supports all features. CPU-only execution is only supported with Celune Mini.
* Be aware that many features require an RTX 30 series GPU or newer.
diff --git a/README.md b/README.md
index 9e036e2..c395e93 100644
--- a/README.md
+++ b/README.md
@@ -331,6 +331,33 @@ Without this, Celune may require elevated permissions or fall back to slower beh
See [API.md](./API.md) for REST API configuration, authentication, endpoints, and cURL examples.
The API allows programmatic usage of all Celune features. It can be used both as a public and local interface.
+## Extensions
+
+Celune comes with extension support, allowing custom code to run with the engine.
+
+Extensions may subscribe at will to certain core events with `@celune.subscribe(...)`.
+
+Celune exposes the following core events to extensions:
+
+- `ready`
+- `shutdown`
+- `fatal`
+- `error`
+- `voice_changed`
+- `state_changed`
+- `generation_start`
+- `generation_end`
+- `generation_error`
+- `audio_start`
+- `audio_end`
+- `character_changed`
+- `character_loaded`
+- `character_unloaded`
+
+For example extension usage, check the [example extension](./extensions/test.py).
+
+The aforementioned extension defines a basic usage case for Celune extensions.
+
## Web UI
Celune exposes a web interface for remote access to Celune. It reuses the Celune API commands to provide an interface for control.
diff --git a/celune/__init__.py b/celune/__init__.py
index b2fe15e..5b712b8 100644
--- a/celune/__init__.py
+++ b/celune/__init__.py
@@ -17,13 +17,21 @@
import sys as _sys
import inspect as _inspect
import subprocess as _subprocess
-from typing import TYPE_CHECKING, Union
+from typing import TYPE_CHECKING, Callable, Union
from .constants import APP_NAME
+from .paths import (
+ configure_huggingface_cache_environment,
+ configure_huggingface_runtime,
+)
+
+configure_huggingface_cache_environment()
+configure_huggingface_runtime()
if TYPE_CHECKING:
from .celune import Celune
from .extensions.base import CeluneContext, CeluneExtension
+ from .extensions.events import subscribe
def _get_revision() -> str:
@@ -60,7 +68,7 @@ def _caller_is_repl() -> bool:
REVISION = _get_revision()
-VERSION = "4.1.2"
+VERSION = "4.2.0"
if REVISION:
_local = REVISION.rstrip("*")
@@ -84,7 +92,12 @@ def _caller_is_repl() -> bool:
def __getattr__(
name: str,
-) -> Union[type["Celune"], type["CeluneContext"], type["CeluneExtension"]]:
+) -> Union[
+ type["Celune"],
+ type["CeluneContext"],
+ type["CeluneExtension"],
+ Callable[..., Callable[..., None]],
+]:
if name == "Celune":
from .celune import Celune
@@ -100,6 +113,11 @@ def __getattr__(
return CeluneExtension
+ if name == "subscribe":
+ from .extensions.events import subscribe
+
+ return subscribe
+
raise AttributeError(f"module '{__name__!r}' has no attribute '{name!r}'")
@@ -108,6 +126,7 @@ def __getattr__(
"CeluneContext",
"CeluneExtension",
"REVISION",
+ "subscribe",
"__version__",
"__tagline__",
"__codename__",
diff --git a/celune/analysis.py b/celune/analysis.py
index fcd29d5..ab6a5a0 100644
--- a/celune/analysis.py
+++ b/celune/analysis.py
@@ -17,7 +17,11 @@
from transformers import AutoModel, AutoProcessor
from .cevoice import ManifestValue, default_loader
-from .constants import VOICE_EMBEDDING_MODEL, N_A_NUMERIC
+from .constants import (
+ VOICE_EMBEDDING_MODEL,
+ N_A_NUMERIC,
+ remote_code_model_revision,
+)
from .typing.analysis import (
EmbeddingPayload,
EmbeddingModel,
@@ -318,11 +322,13 @@ def _load_embedding_model() -> tuple[EmbeddingProcessor, EmbeddingModel]:
global _EMBEDDING_MODEL, _EMBEDDING_PROCESSOR
if _EMBEDDING_MODEL is None or _EMBEDDING_PROCESSOR is None:
+ revision = remote_code_model_revision(VOICE_EMBEDDING_MODEL)
_EMBEDDING_PROCESSOR = cast(
EmbeddingProcessor,
AutoProcessor.from_pretrained(
VOICE_EMBEDDING_MODEL,
trust_remote_code=True,
+ revision=revision,
),
)
_EMBEDDING_MODEL = cast(
@@ -330,6 +336,7 @@ def _load_embedding_model() -> tuple[EmbeddingProcessor, EmbeddingModel]:
AutoModel.from_pretrained(
VOICE_EMBEDDING_MODEL,
trust_remote_code=True,
+ revision=revision,
),
)
_EMBEDDING_MODEL.eval()
diff --git a/celune/api.py b/celune/api.py
index a930f25..f660efe 100644
--- a/celune/api.py
+++ b/celune/api.py
@@ -9,18 +9,19 @@
import datetime
import textwrap
import threading
+from html import escape
from hmac import compare_digest
from dataclasses import dataclass
-from html import escape
from collections import defaultdict, deque
from typing import Callable, Iterator, Optional, Union
-import gradio as gr
-import uvicorn
import numpy as np
import numpy.typing as npt
+import uvicorn
+import gradio as gr
import soundfile as sf
from pydantic import BaseModel, Field
+from starlette.concurrency import run_in_threadpool
from starlette.middleware.base import RequestResponseEndpoint
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import (
@@ -31,17 +32,18 @@
RedirectResponse,
)
-from . import __version__
from . import colors
+from . import __version__
from .celune import Celune
+from .ui.app import CeluneUI
from .utils import format_error
-from .paths import main_window_log_path, project_root
from .dsp import resample_audio
-from .pipeline import SpeechStreamQueue
-from .constants import BASE_SR, APP_NAME, JSONSerializable
from .cevoice import default_loader
+from .pipeline import SpeechStreamQueue, prepare_playback_audio
from .ui import resources as ui_resources
-from .ui.app import CeluneUI
+from .paths import main_window_log_path, project_root
+from .constants import BASE_SR, APP_NAME, JSONSerializable
+from .i18n import string
api = FastAPI(title=f"{APP_NAME}API")
bound_celune: Optional[Celune] = None
@@ -61,7 +63,7 @@
webui_last_resource_advance = 0.0
webui_last_probed_state: Optional[str] = None
webui_input_locked = True
-webui_input_placeholder = "Please wait"
+webui_input_placeholder = string("webui.wait_placeholder")
webui_voice_locked = True
webui_theme_style = ""
webui_status_source = "probe"
@@ -73,6 +75,8 @@
WebUiUpdate = dict[str, JSONSerializable]
WebUiAudioValue = Optional[tuple[int, npt.NDArray[np.float32]]]
+WebUiInputArray = Union[npt.NDArray[np.float32], npt.NDArray[np.int16]]
+WebUiInputAudioValue = Optional[tuple[int, WebUiInputArray]]
class _WebUiUnset:
@@ -96,6 +100,7 @@ class _WebUiUnset:
html,
body,
gradio-app {
+ --color-accent: var(--celune-primary, #cebaff) !important;
background: var(--celune-ui-bg, var(--celune-background, #1d1826)) !important;
}
@@ -109,11 +114,22 @@ class _WebUiUnset:
height: 100dvh;
overflow: hidden;
}
+
+ .gradio-container .tab-container::after {
+ display: none;
+ }
.gradio-container > .main,
.gradio-container .wrap,
+ .gradio-container .block,
+ .gradio-container .form,
+ .gradio-container label,
+ .gradio-container label.selected,
+ .gradio-container .tab-like-container,
+ .gradio-container .tab-like-container input,
.gradio-container .loading-container,
- .gradio-container .loading-container > div {
+ .gradio-container .loading-container > div,
+ .gradio-container button[role="tab"]:hover {
background: var(--celune-ui-bg, var(--celune-background, #1d1826)) !important;
}
@@ -152,13 +168,13 @@ class _WebUiUnset:
color: var(--celune-ui-accent, var(--celune-primary, #cebaff));
}
- button#celune-style, button#celune-send {
+ button#celune-style, button#celune-send, button#celune-convert {
background: var(--celune-button-bg, #3a304c);
color: var(--celune-ui-accent, var(--celune-primary, #cebaff));
border-radius: 4px;
}
- button#celune-style:hover, button#celune-send:hover {
+ button#celune-style:hover, button#celune-send:hover, button#celune-convert:hover {
background: var(--celune-button-hover, #443a56);
}
@@ -487,12 +503,35 @@ def _authenticated(request: Request) -> bool:
return given is not None and compare_digest(given, auth_token)
-def _is_browser_ui_request(request: Request) -> bool:
- """Return whether the request targets the mounted browser UI."""
+def is_browser_ui_request(request: Request) -> bool:
+ """Return whether the request targets the mounted browser UI.
+
+ Args:
+ request: Incoming HTTP request to classify.
+
+ Returns:
+ bool: ``True`` when the request path points at the mounted WebUI.
+ """
path = request.url.path.rstrip("/")
return path == "/ui" or path.startswith("/ui/")
+def _is_public_api_request(request: Request) -> bool:
+ """Return whether the request is safe to serve without an API token."""
+ method = request.method.upper()
+ path = request.url.path.rstrip("/") or "/"
+
+ if is_browser_ui_request(request):
+ return True
+
+ return method == "GET" and path in {
+ "/",
+ "/favicon.ico",
+ "/v1",
+ "/v1/version",
+ }
+
+
def _rate_limit_key(request: Request) -> str:
"""Return the client key used for rate limiting."""
if request.client is None:
@@ -535,7 +574,7 @@ async def api_security(
Returns:
Response: The response returned by the protected route or security layer.
"""
- if _is_browser_ui_request(request):
+ if _is_public_api_request(request):
return await call_next(request)
if not _authenticated(request):
@@ -543,7 +582,7 @@ async def api_security(
status_code=401,
content={
"error": "unauthorized",
- "message": "Who are you? Send me an authentication token.",
+ "message": string("api.unauthorized"),
},
headers={"WWW-Authenticate": "Bearer"},
)
@@ -559,7 +598,7 @@ async def api_security(
status_code=429,
content={
"error": "ratelimit_exceeded",
- "message": "Please wait until you make me speak again.",
+ "message": string("api.rate_limit"),
},
headers={"Retry-After": str(retry_after)},
)
@@ -583,12 +622,10 @@ def bind_celune(celune: Celune) -> None:
_configure_webui_theme()
has_voice = bool(celune.current_voice) or bool(celune.voices)
webui_input_locked = celune.locked or not has_voice
- webui_input_placeholder = (
- "Currently in tutorial mode"
- if celune.is_in_tutorial
- else "Please wait"
- if celune and webui_input_locked
- else "Enter text to speak here"
+ webui_input_placeholder = _webui_input_placeholder(
+ celune,
+ webui_input_locked,
+ has_voice,
)
webui_voice_locked = (
len(celune.voices) < 2 or celune.is_in_tutorial or not has_voice
@@ -596,9 +633,11 @@ def bind_celune(celune: Celune) -> None:
_seed_webui_logs()
_wrap_celune_callbacks(celune)
if celune.current_voice:
- _append_webui_log(f"Voice ready: {celune.current_voice}.")
+ _append_webui_log(string("webui.voice_ready", voice=celune.current_voice))
_set_webui_status(
- "Idle" if celune.cur_state == "idle" else celune.cur_state.title(),
+ string("status.idle")
+ if celune.cur_state == "idle"
+ else celune.cur_state.title(),
source="probe",
)
@@ -690,20 +729,23 @@ def _set_webui_status(
def _probed_status_text(celune: Celune) -> tuple[str, str]:
"""Return the best-effort footer status derived from Celune's live state."""
if not celune.current_voice and not celune.voices:
- return (f"{APP_NAME} could not start", "error")
+ return string("status.could_not_start", app_name=APP_NAME), "error"
state = (celune.cur_state or "").strip().lower()
return {
- "idle": ("Idle", "info"),
- "speaking": ("Speaking", "info"),
- "thinking": ("Thinking", "info"),
- "waking": ("Waking up", "info"),
- "reloading": ("Reloading", "info"),
- "sleeping": ("Sleeping", "sleeping"),
- "init": ("Initializing", "info"),
- "generating": ("Generating", "info"),
- "error": (f"{APP_NAME} could not continue", "error"),
- }.get(state, (state.title() if state else "Initializing", "info"))
+ "idle": (string("status.idle"), "info"),
+ "speaking": (string("status.speaking"), "info"),
+ "thinking": (string("status.thinking"), "info"),
+ "waking": (string("status.waking_up"), "info"),
+ "reloading": (string("status.reloading"), "info"),
+ "sleeping": (string("status.sleeping"), "sleeping"),
+ "init": (string("status.initializing"), "info"),
+ "generating": (string("status.generating"), "info"),
+ "error": (string("status.could_not_continue", app_name=APP_NAME), "error"),
+ }.get(
+ state,
+ (state.title() if state else string("status.initializing"), "info"),
+ )
def _probe_webui_runtime() -> None:
@@ -719,13 +761,13 @@ def _probe_webui_runtime() -> None:
if current_state != webui_last_probed_state:
if current_state == "sleeping":
_append_webui_log(
- f"{APP_NAME} is currently sleeping. Type anything to wake up.",
+ string("webui.sleeping_log", app_name=APP_NAME),
"sleeping",
)
status_text, severity = _probed_status_text(celune)
should_override_status = (
webui_last_probed_state is None
- or webui_status_text == "Starting up"
+ or webui_status_text == string("status.api_starting")
or webui_status_source != "callback"
or now - webui_status_updated_at >= WEBUI_STATUS_PROBE_DEBOUNCE_SECONDS
or current_state in {"idle", "sleeping", "error"}
@@ -772,19 +814,17 @@ def wrapped_status(msg: str, severity: str = "info") -> None:
original_status(msg, severity)
def wrapped_voice_changed(name: str) -> None:
- _append_webui_log(f"Voice changed to {name}.")
+ _append_webui_log(string("webui.voice_changed", voice=name))
original_voice_changed(name)
def wrapped_input_state(locked: bool) -> None:
global webui_input_locked, webui_input_placeholder
has_voice = bool(celune.current_voice) or bool(celune.voices)
webui_input_locked = locked or not has_voice
- webui_input_placeholder = (
- "Currently in tutorial mode"
- if celune.is_in_tutorial
- else "Please wait"
- if celune and webui_input_locked
- else "Enter text to speak here"
+ webui_input_placeholder = _webui_input_placeholder(
+ celune,
+ webui_input_locked,
+ has_voice,
)
original_input_state(locked)
@@ -827,7 +867,7 @@ def require_celune() -> Celune:
if bound_celune is None:
raise HTTPException(
status_code=503,
- detail="I'm not currently available.",
+ detail=string("webui.not_available"),
)
return bound_celune
@@ -860,13 +900,13 @@ def _normalized_audio(audio: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]
return normalized
-def _flac_bytes(audio: npt.NDArray[np.float32]) -> bytes:
- """Encode 48 kHz audio as PCM24 FLAC bytes."""
+def _flac_bytes(audio: npt.NDArray[np.float32], sample_rate: int = BASE_SR) -> bytes:
+ """Encode audio as PCM24 FLAC bytes."""
buffer = io.BytesIO()
sf.write(
buffer,
_normalized_audio(audio),
- BASE_SR,
+ sample_rate,
format="FLAC",
subtype="PCM_24",
)
@@ -919,15 +959,18 @@ def _webui_audio_array(chunks: SpeechStreamQueue) -> npt.NDArray[np.float32]:
return np.concatenate(audio_chunks)
-def stream_headers() -> dict[str, str]:
+def stream_headers(sample_rate: int = BASE_SR) -> dict[str, str]:
"""Return headers describing the FLAC response.
+ Args:
+ sample_rate: Sample rate advertised in the response headers.
+
Returns:
dict[str, str]: Response headers for a FLAC response.
"""
return {
"X-Audio-Format": "flac-pcm24",
- "X-Sample-Rate": str(BASE_SR),
+ "X-Sample-Rate": str(sample_rate),
"X-Channels": "2",
}
@@ -1033,6 +1076,85 @@ def _webui_resources_html() -> str:
return f'
'
+def _webui_shortcuts_html() -> str:
+ """Render browser-side keyboard shortcuts for WebUI-only controls."""
+ recording_shortcut = string("ui.footer_toggle_recording")
+ script = textwrap.dedent(
+ """
+
+ """
+ ).strip()
+ return script.replace(
+ "__CELUNE_RECORDING_SHORTCUT__",
+ repr(recording_shortcut)[1:-1],
+ )
+
+
def _voice_button_update() -> WebUiUpdate:
"""Return the current browser voice-button state."""
celune = bound_celune
@@ -1054,6 +1176,29 @@ def _voice_button_update() -> WebUiUpdate:
)
+def _webui_vc_mode_active(celune: Optional[Celune]) -> bool:
+ """Return whether the browser UI should expose active VC controls."""
+ return bool(
+ celune is not None
+ and getattr(celune, "input_mode", "text_to_speech") == "voice_conversion"
+ )
+
+
+def _webui_input_placeholder(
+ celune: Celune,
+ locked: bool,
+ has_voice: bool,
+) -> str:
+ """Return the current browser input placeholder string."""
+ if celune.is_in_tutorial:
+ return string("webui.tutorial_placeholder")
+ if locked or not has_voice:
+ return string("webui.wait_placeholder")
+ if _webui_vc_mode_active(celune):
+ return string("webui.voice_changer_placeholder")
+ return string("webui.input_placeholder")
+
+
def _input_update(
value: Union[Optional[str], _WebUiUnset] = _WEBUI_UNSET,
) -> WebUiUpdate:
@@ -1065,34 +1210,30 @@ def _input_update(
return gr.update(
value=value,
interactive=False,
- placeholder="Please wait",
+ placeholder=string("webui.wait_placeholder"),
)
return gr.update(
interactive=False,
- placeholder="Please wait",
+ placeholder=string("webui.wait_placeholder"),
)
if celune.is_in_tutorial:
if has_value:
return gr.update(
value=value,
interactive=False,
- placeholder="Currently in tutorial mode",
+ placeholder=string("webui.tutorial_placeholder"),
)
return gr.update(
interactive=False,
- placeholder="Currently in tutorial mode",
+ placeholder=string("webui.tutorial_placeholder"),
)
has_voice = bool(celune.current_voice) or bool(celune.voices)
if getattr(celune, "_webui_callbacks_wrapped", False):
interactive = not webui_input_locked and has_voice
- placeholder = webui_input_placeholder
+ placeholder = _webui_input_placeholder(celune, webui_input_locked, has_voice)
else:
interactive = not celune.locked and has_voice
- placeholder = (
- "Please wait"
- if celune and (celune.locked or not has_voice)
- else "Enter text to speak here"
- )
+ placeholder = _webui_input_placeholder(celune, celune.locked, has_voice)
if has_value:
return gr.update(
value=value,
@@ -1119,6 +1260,24 @@ def _send_button_update() -> WebUiUpdate:
return gr.update(interactive=interactive)
+def _webui_vc_controls_update() -> tuple[
+ WebUiUpdate,
+ WebUiUpdate,
+ WebUiUpdate,
+ WebUiUpdate,
+]:
+ """Return the current browser VC control state."""
+ celune = bound_celune
+ vc_enabled = _webui_vc_mode_active(celune)
+ control_interactive = celune is not None and vc_enabled
+ return (
+ gr.update(interactive=control_interactive),
+ gr.update(interactive=control_interactive),
+ gr.update(interactive=control_interactive),
+ gr.update(interactive=control_interactive),
+ )
+
+
def _webui_snapshot() -> tuple[
str,
str,
@@ -1174,7 +1333,7 @@ def _webui_run_command(text: str) -> bool:
ui = CeluneUI._instance
if ui is None:
_append_webui_log(
- f"{APP_NAME} must be running to run commands.",
+ string("webui.must_be_running_for_commands", app_name=APP_NAME),
"warning",
)
return False
@@ -1182,7 +1341,7 @@ def _webui_run_command(text: str) -> bool:
try:
parts = CeluneUI.split_command_input(text[1:])
except ValueError as e:
- _append_webui_log(f"Command parsing error: {e}", "error")
+ _append_webui_log(string("webui.command_parsing_error", error=e), "error")
return False
if not parts:
@@ -1194,6 +1353,42 @@ def _webui_run_command(text: str) -> bool:
return True
+def _decode_uploaded_audio(
+ data: bytes,
+) -> tuple[npt.NDArray[np.float32], int]:
+ """Decode uploaded audio bytes into float32 audio and a source sample rate."""
+ audio, sample_rate = sf.read(io.BytesIO(data), dtype="float32")
+ return np.asarray(audio, dtype=np.float32), int(sample_rate)
+
+
+def _normalize_webui_audio_input(
+ source_audio: WebUiInputAudioValue,
+) -> WebUiAudioValue:
+ """Normalize one Gradio audio value to Celune's float32 waveform contract."""
+ if source_audio is None:
+ return None
+
+ sample_rate, audio = source_audio
+ normalized = np.asarray(audio)
+ if normalized.dtype == np.int16:
+ normalized = normalized.astype(np.float32) / 32768.0
+ else:
+ normalized = normalized.astype(np.float32, copy=False)
+
+ return sample_rate, np.ascontiguousarray(normalized, dtype=np.float32)
+
+
+def _voice_conversion_unavailable_response() -> JSONResponse:
+ """Return a standard API error for VC-only endpoints in TTS mode."""
+ return JSONResponse(
+ status_code=409,
+ content={
+ "error": "wrong_mode",
+ "message": string("webui.wrong_mode"),
+ },
+ )
+
+
def _webui_speak(
content: str,
) -> Iterator[
@@ -1226,14 +1421,14 @@ def _webui_speak(
current_state = (celune.cur_state or "").strip().lower()
if current_state == "waking":
_append_webui_log(
- f"{APP_NAME} has not yet returned from sleep mode.", "warning"
+ string("webui.not_returned_from_sleep", app_name=APP_NAME), "warning"
)
snapshot = _webui_submit_snapshot(text)
yield snapshot[0], None, *snapshot[1:]
return
if getattr(celune, "sleeping", False):
- _set_webui_status("Waking up")
+ _set_webui_status(string("status.waking_up"))
snapshot = _webui_submit_snapshot(text)
yield snapshot[0], None, *snapshot[1:]
if not celune.wake_from_sleep():
@@ -1243,7 +1438,7 @@ def _webui_speak(
chunks = celune.say_stream(text, save=True)
if chunks is None:
- _append_webui_log("I'm currently busy. Try again later.", "warning")
+ _append_webui_log(string("webui.busy_try_again"), "warning")
snapshot = _webui_submit_snapshot(text)
yield snapshot[0], None, *snapshot[1:]
return
@@ -1271,15 +1466,126 @@ def _webui_speak(
yield snapshot[0], audio_value, *snapshot[1:]
except Exception as e:
_append_webui_log(
- f"[WEBUI ERROR] {format_error(e, celune.dev)}",
+ string("webui.error", error=format_error(e, celune.dev)),
"error",
)
snapshot = _webui_submit_snapshot("")
yield snapshot[0], None, *snapshot[1:]
+def _webui_convert_audio(
+ source_audio: WebUiInputAudioValue,
+ pitch_shift: float = 0.0,
+ conversion_mode: str = "talk",
+) -> tuple[
+ WebUiInputAudioValue,
+ WebUiAudioValue,
+ str,
+ str,
+ str,
+ WebUiUpdate,
+ WebUiUpdate,
+]:
+ """Convert uploaded audio through the active VC backend for browser playback."""
+ if source_audio is None:
+ _append_webui_log(
+ string("webui.upload_audio_first"),
+ "warning",
+ )
+ logs_html, status_html, resources_html, voice_update, send_update, _input = (
+ _webui_snapshot()
+ )
+ return (
+ None,
+ None,
+ logs_html,
+ status_html,
+ resources_html,
+ voice_update,
+ send_update,
+ )
+
+ celune = require_celune()
+ if getattr(celune, "input_mode", "text_to_speech") != "voice_conversion":
+ _append_webui_log(
+ string("webui.conversion_only_in_vc_mode"),
+ "warning",
+ )
+ logs_html, status_html, resources_html, voice_update, send_update, _input = (
+ _webui_snapshot()
+ )
+ return (
+ source_audio,
+ None,
+ logs_html,
+ status_html,
+ resources_html,
+ voice_update,
+ send_update,
+ )
+
+ normalized_source_audio = _normalize_webui_audio_input(source_audio)
+ assert normalized_source_audio is not None
+ sample_rate, audio = normalized_source_audio
+ api_log("CONVERT(WEBUI)", "uploaded audio")
+ try:
+ output = celune.convert_audio(
+ audio,
+ sample_rate,
+ label="browser audio input",
+ pitch_shift=round(pitch_shift),
+ f0_condition=conversion_mode.strip().lower() == "sing",
+ )
+ except Exception as e:
+ _append_webui_log(
+ string("webui.error", error=format_error(e, celune.dev)),
+ "error",
+ )
+ logs_html, status_html, resources_html, voice_update, send_update, _input = (
+ _webui_snapshot()
+ )
+ return (
+ source_audio,
+ None,
+ logs_html,
+ status_html,
+ resources_html,
+ voice_update,
+ send_update,
+ )
+
+ if output is None:
+ _append_webui_log(string("webui.cannot_convert_right_now"), "warning")
+ logs_html, status_html, resources_html, voice_update, send_update, _input = (
+ _webui_snapshot()
+ )
+ return (
+ source_audio,
+ None,
+ logs_html,
+ status_html,
+ resources_html,
+ voice_update,
+ send_update,
+ )
+
+ prepared_audio = prepare_playback_audio(output.audio, output.sample_rate)
+ converted_audio = (BASE_SR, prepared_audio)
+ logs_html, status_html, resources_html, voice_update, send_update, _input = (
+ _webui_snapshot()
+ )
+ return (
+ None,
+ converted_audio,
+ logs_html,
+ status_html,
+ resources_html,
+ voice_update,
+ send_update,
+ )
+
+
configure_webui_theme = _configure_webui_theme
-is_browser_ui_request = _is_browser_ui_request
webui_theme_html = _webui_theme_html
strip_webui_log_prefix = _strip_webui_log_prefix
set_webui_status = _set_webui_status
@@ -1310,7 +1616,7 @@ def _webui_cycle_voice() -> tuple[
api_log("VOICE(WEBUI)", next_voice)
if not celune.set_voice_and_wait(next_voice):
- _append_webui_log("I can't change my voice right now.", "error")
+ _append_webui_log(string("webui.cannot_change_voice_right_now"), "error")
return _webui_snapshot()
@@ -1324,58 +1630,105 @@ def _build_webui() -> gr.Blocks:
) as demo:
gr.HTML(webui_theme_style)
with gr.Column(elem_id="celune-shell"):
- gr.HTML(
- textwrap.dedent(
- f"""
-
- """
- )
- )
- logs = gr.HTML(_webui_logs_html())
- with gr.Row(elem_id="celune-input-row"):
- input_box = gr.Textbox(
- value="",
- lines=1,
- max_lines=4,
- show_label=False,
- placeholder="Please wait",
- container=False,
- elem_id="celune-input",
- scale=8,
- interactive=False,
- )
- with gr.Row(elem_id="celune-actions", scale=2):
- voice_button = gr.Button(
- value="Balanced",
- elem_id="celune-style",
- scale=1,
- min_width=0,
- interactive=False,
+ with gr.Tabs():
+ with gr.Tab(string("webui.tts_tab_label")):
+ gr.HTML(
+ textwrap.dedent(
+ f"""
+
+ """
+ )
)
- send_button = gr.Button(
- value="Send",
- elem_id="celune-send",
- scale=1,
- min_width=0,
- interactive=False,
+ logs = gr.HTML(_webui_logs_html())
+ with gr.Row(elem_id="celune-input-row"):
+ input_box = gr.Textbox(
+ value="",
+ lines=1,
+ max_lines=4,
+ show_label=False,
+ placeholder=string("webui.wait_placeholder"),
+ container=False,
+ elem_id="celune-input",
+ scale=8,
+ interactive=False,
+ )
+ with gr.Row(elem_id="celune-actions", scale=2):
+ voice_button = gr.Button(
+ value=string("webui.default_voice_button"),
+ elem_id="celune-style",
+ scale=1,
+ min_width=0,
+ interactive=False,
+ )
+ send_button = gr.Button(
+ value=string("webui.send_button"),
+ elem_id="celune-send",
+ scale=1,
+ min_width=0,
+ interactive=False,
+ )
+ with gr.Row(elem_id="celune-footer"):
+ status = gr.HTML(_webui_status_html(), elem_id="celune-status")
+ resources = gr.HTML(
+ _webui_resources_html(),
+ elem_id="celune-resources",
+ )
+ gr.HTML(
+ textwrap.dedent(f"""
+
+ {string("webui.features_may_differ", app_name=APP_NAME)}
+
+ """)
)
- with gr.Row(elem_id="celune-footer"):
- status = gr.HTML(_webui_status_html(), elem_id="celune-status")
- resources = gr.HTML(
- _webui_resources_html(),
- elem_id="celune-resources",
- )
- gr.HTML(
- textwrap.dedent(f"""
-
- Usage may differ. Some {APP_NAME} features may not be available.
-
- """)
- )
+ with gr.Tab(string("webui.vc_tab_label")):
+ with gr.Column(elem_id="celune-convert-panel"):
+ source_audio = gr.Audio(
+ value=None,
+ type="numpy",
+ sources=["upload", "microphone"],
+ autoplay=False,
+ show_label=True,
+ label=string("webui.source_audio_label"),
+ interactive=False,
+ elem_id="celune-source-audio",
+ )
+ vc_pitch_shift = gr.Slider(
+ minimum=-12,
+ maximum=12,
+ step=1,
+ value=0,
+ label=string("webui.pitch_shift_label"),
+ info=string("webui.pitch_shift_info"),
+ interactive=False,
+ )
+ vc_mode = gr.Radio(
+ choices=[
+ ("Talk", "talk"),
+ ("Sing", "sing"),
+ ],
+ value="talk",
+ label=string("webui.conversion_mode_label"),
+ info=string("webui.conversion_mode_info"),
+ interactive=False,
+ )
+ convert_button = gr.Button(
+ value=string("webui.convert_button"),
+ elem_id="celune-convert",
+ interactive=False,
+ )
+ converted_audio = gr.Audio(
+ value=None,
+ type="numpy",
+ autoplay=True,
+ show_label=False,
+ interactive=False,
+ visible="hidden",
+ elem_id="celune-converted-audio",
+ )
audio = gr.Audio(
value=None,
type="numpy",
@@ -1391,11 +1744,21 @@ def _build_webui() -> gr.Blocks:
outputs=[logs, status, resources, voice_button, send_button, input_box],
show_progress="hidden",
)
+ timer.tick( # pylint: disable=E1101
+ _webui_vc_controls_update,
+ outputs=[source_audio, vc_pitch_shift, vc_mode, convert_button],
+ show_progress="hidden",
+ )
demo.load( # pylint: disable=E1101
_webui_snapshot,
outputs=[logs, status, resources, voice_button, send_button, input_box],
show_progress="hidden",
)
+ demo.load( # pylint: disable=E1101
+ _webui_vc_controls_update,
+ outputs=[source_audio, vc_pitch_shift, vc_mode, convert_button],
+ show_progress="hidden",
+ )
input_box.submit( # pylint: disable=E1101
_webui_speak,
inputs=[input_box],
@@ -1429,6 +1792,20 @@ def _build_webui() -> gr.Blocks:
outputs=[logs, status, resources, voice_button, send_button, input_box],
show_progress="hidden",
)
+ convert_button.click( # pylint: disable=E1101
+ _webui_convert_audio,
+ inputs=[source_audio, vc_pitch_shift, vc_mode],
+ outputs=[
+ source_audio,
+ converted_audio,
+ logs,
+ status,
+ resources,
+ voice_button,
+ send_button,
+ ],
+ show_progress="hidden",
+ )
return demo
@@ -1537,7 +1914,7 @@ def speak(body: SpeakRequest) -> Union[StreamingResponse, JSONResponse]:
status_code=409,
content={
"error": "not_ready",
- "message": "I'm currently busy. Try again later.",
+ "message": string("webui.busy_try_again"),
},
)
@@ -1566,7 +1943,7 @@ def speak_async(body: SpeakRequest) -> JSONResponse:
status_code=409,
content={
"error": "not_ready",
- "message": "I'm currently busy. Try again later.",
+ "message": string("webui.busy_try_again"),
},
)
@@ -1605,7 +1982,7 @@ def think(body: ThinkRequest) -> JSONResponse:
status_code=409,
content={
"error": "not_ready",
- "message": "I'm currently busy. Try again later.",
+ "message": string("webui.busy_try_again"),
},
)
@@ -1628,7 +2005,7 @@ def speak_job(job_id: str) -> Union[Response, JSONResponse]:
status_code=404,
content={
"error": "not_found",
- "message": "I don't know that speech job.",
+ "message": string("api.speech_job_unknown"),
},
)
@@ -1665,7 +2042,7 @@ def voice(body: VoiceRequest) -> Union[ActionResponse, JSONResponse]:
status_code=400,
content={
"error": "invalid_value",
- "message": "I don't know how to speak in that voice.",
+ "message": string("api.invalid_voice"),
},
)
@@ -1674,7 +2051,7 @@ def voice(body: VoiceRequest) -> Union[ActionResponse, JSONResponse]:
status_code=500,
content={
"error": "request_failed",
- "message": "I can't change my voice right now.",
+ "message": string("webui.cannot_change_voice_right_now"),
},
)
@@ -1706,28 +2083,30 @@ async def sfx(
status_code=413,
content={
"error": "request_too_large",
- "message": "That sound is too large for me to play.",
+ "message": string("api.sound_too_large"),
},
)
try:
- audio, sr = sf.read(io.BytesIO(data), dtype="float32")
- audio = resample_audio(np.asarray(audio, dtype=np.float32), sr)
- except Exception as e:
+ audio, sr = _decode_uploaded_audio(data)
+ audio = resample_audio(audio, sr)
+ except Exception:
return JSONResponse(
status_code=400,
content={
"error": "invalid_audio",
- "message": f"I can't understand that sound file: {format_error(e, celune.dev)}",
+ "message": string("api.invalid_input"),
},
)
- if not celune.play_audio(audio, BASE_SR, label=filename, keep=keep):
+ if not await run_in_threadpool(
+ celune.play_audio, audio, BASE_SR, label=filename, keep=keep
+ ):
return JSONResponse(
status_code=409,
content={
"error": "not_ready",
- "message": "I can't play that right now.",
+ "message": string("api.cannot_play_now"),
},
)
@@ -1741,6 +2120,92 @@ def chunks() -> Iterator[bytes]:
)
+@api.post("/v1/convert", response_model=None)
+async def convert_audio(
+ file: UploadFile = File(...),
+ pitch_shift: Optional[int] = Form(None),
+ f0_condition: Optional[bool] = Form(None),
+) -> Union[StreamingResponse, JSONResponse]:
+ """Convert an uploaded source audio file through the active VC backend.
+
+ Args:
+ file: The uploaded source audio file to convert.
+ pitch_shift: Optional semitone adjustment applied for this conversion only.
+ f0_condition: Optional override enabling singing mode pitch conditioning.
+
+ Returns:
+ Union[StreamingResponse, JSONResponse]: The converted audio stream, or a JSON error payload if conversion
+ failed.
+ """
+ celune = require_celune()
+ if getattr(celune, "input_mode", "text_to_speech") != "voice_conversion":
+ return _voice_conversion_unavailable_response()
+
+ filename = file.filename or f"convert_{uuid.uuid4()}"
+ api_log("CONVERT", filename)
+
+ data = await file.read(max_sfx_upload_bytes + 1)
+ if len(data) > max_sfx_upload_bytes:
+ return JSONResponse(
+ status_code=413,
+ content={
+ "error": "request_too_large",
+ "message": string("api.source_audio_too_large"),
+ },
+ )
+
+ try:
+ audio, sample_rate = _decode_uploaded_audio(data)
+ except Exception:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "invalid_audio",
+ "message": string("api.invalid_input"),
+ },
+ )
+
+ try:
+ output = await run_in_threadpool(
+ celune.convert_audio,
+ audio,
+ sample_rate,
+ label=filename,
+ pitch_shift=pitch_shift,
+ f0_condition=f0_condition,
+ )
+ except Exception:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": "request_failed",
+ "message": string("api.could_not_convert"),
+ },
+ )
+
+ if output is None:
+ return JSONResponse(
+ status_code=409,
+ content={
+ "error": "not_ready",
+ "message": string("api.cannot_convert"),
+ },
+ )
+
+ def chunks() -> Iterator[bytes]:
+ prepared_audio = prepare_playback_audio(output.audio, output.sample_rate)
+ yield _flac_bytes(
+ prepared_audio,
+ sample_rate=BASE_SR,
+ )
+
+ return StreamingResponse(
+ chunks(),
+ media_type="audio/flac",
+ headers=stream_headers(BASE_SR),
+ )
+
+
api = gr.mount_gradio_app(
api,
_build_webui(),
@@ -1778,8 +2243,12 @@ def run_api(
bind_host = resolve_api_host(token=auth_token, host=host)
def _default_started(bhost: str, bport: int) -> None:
- http = "http"
- message = f"{APP_NAME} API has started on {http}://{bhost}:{bport}"
+ message = string(
+ "api.runner_started",
+ app_name=APP_NAME,
+ host=bhost,
+ port=bport,
+ )
if celune is not None:
celune.log(message)
else:
@@ -1830,8 +2299,14 @@ def start_api(
failed = threading.Event()
def _started(bind_host: str, bind_port: int) -> None:
- http = "http"
- celune.log(f"{APP_NAME} API has started on {http}://{bind_host}:{bind_port}")
+ celune.log(
+ string(
+ "api.runner_started",
+ app_name=APP_NAME,
+ host=bind_host,
+ port=bind_port,
+ )
+ )
started.set()
def _runner() -> None:
@@ -1848,11 +2323,15 @@ def _runner() -> None:
except SystemExit as exc:
if exc.code not in (0, None):
failed.set()
- celune.log(f"API runner has exited. Exit code {exc.code}", "warning")
+ celune.log(
+ string("api.runner_exit_code", code=exc.code),
+ "warning",
+ )
except Exception as e:
failed.set()
celune.log(
- f"Could not start the API: {format_error(e, celune.dev)}", "warning"
+ string("api.could_not_start", error=format_error(e, celune.dev)),
+ "warning",
)
thread = threading.Thread(target=_runner, daemon=True, name=f"{APP_NAME}API")
@@ -1863,7 +2342,7 @@ def _runner() -> None:
if not started.is_set() and not failed.is_set():
celune.log(
- f"API runner has not responded after {startup_timeout:.1f}s, and has timed out.",
+ string("api.runner_timeout", seconds=startup_timeout),
"warning",
)
diff --git a/celune/backends/__init__.py b/celune/backends/__init__.py
index ca7c68e..83f5d13 100644
--- a/celune/backends/__init__.py
+++ b/celune/backends/__init__.py
@@ -1,83 +1,2 @@
# SPDX-License-Identifier: MIT
-"""Celune backend initialization manager."""
-
-from importlib import import_module
-from importlib.metadata import version, PackageNotFoundError
-from typing import Callable, Union, Optional
-
-from ..typing.backends import BackendModel
-from .base import CeluneBackend
-
-__all__ = ["BackendModel", "CeluneBackend", "get_version", "resolve_backend"]
-
-BACKENDS = {
- "mini": ("celune.backends.mini", "Mini"),
- "qwen3": ("celune.backends.qwen3", "Qwen3"),
- "dotstts": ("celune.backends.dotstts", "DotsTtsMF"),
- "voxcpm2": ("celune.backends.voxcpm2", "VoxCPM2"),
-}
-
-
-def _default_log(_msg: str, _severity: str = "info") -> None:
- """Default log signature for type checking."""
-
-
-def get_version(package: str) -> str:
- """Get an installed package version.
-
- Args:
- package: The package name to resolve through import metadata.
-
- Returns:
- str: The installed package version, or ``"unknown"`` when the package cannot be found.
- """
- try:
- return version(package)
- except PackageNotFoundError:
- return "unknown"
-
-
-def resolve_backend(
- backend_name: Union[str, type[CeluneBackend], CeluneBackend],
- log: Optional[Callable[[str, str], None]] = None,
- **backend_kwargs,
-) -> CeluneBackend:
- """Resolve a backend specification into a backend instance.
-
- Args:
- backend_name: A backend name, backend class, or backend instance.
- log: Optional log callback to expose during backend construction.
- backend_kwargs: Backend-specific constructor options.
-
- Returns:
- CeluneBackend: The resolved backend instance.
-
- Raises:
- ValueError: The named backend is unknown.
- TypeError: The backend specification is not a supported type.
- """
- log = log or _default_log
-
- if isinstance(backend_name, CeluneBackend):
- return backend_name
-
- if isinstance(backend_name, type) and issubclass(backend_name, CeluneBackend):
- return backend_name(log=log, **backend_kwargs)
-
- if isinstance(backend_name, str):
- key = backend_name.strip().lower()
-
- try:
- module_name, class_name = BACKENDS[key]
- except KeyError as e:
- raise ValueError(
- f"unknown backend: '{backend_name}' (available: {', '.join(BACKENDS.keys())})"
- ) from e
-
- module = import_module(module_name)
- backend_cls = getattr(module, class_name)
- return backend_cls(log=log, **backend_kwargs)
-
- raise TypeError(
- "'backend_name' must be a backend instance, backend type, or backend name string"
- )
+"""Backend package namespace for Celune."""
diff --git a/celune/backends/tts/__init__.py b/celune/backends/tts/__init__.py
new file mode 100644
index 0000000..91baffe
--- /dev/null
+++ b/celune/backends/tts/__init__.py
@@ -0,0 +1,86 @@
+# SPDX-License-Identifier: MIT
+"""Celune backend initialization manager."""
+
+from typing import Callable, Union, Optional
+from importlib import import_module
+from importlib.metadata import version, PackageNotFoundError
+
+from .base import CeluneBackend
+from ...i18n import string
+from ...typing.backends import BackendModel
+
+__all__ = ["BackendModel", "CeluneBackend", "get_version", "resolve_backend"]
+
+BACKENDS = {
+ "mini": ("celune.backends.tts.mini", "Mini"),
+ "qwen3": ("celune.backends.tts.qwen3", "Qwen3"),
+ "dotstts": ("celune.backends.tts.dotstts", "DotsTtsMF"),
+ "voxcpm2": ("celune.backends.tts.voxcpm2", "VoxCPM2"),
+}
+
+
+def _default_log(_msg: str, _severity: str = "info") -> None:
+ """Default log signature for type checking."""
+
+
+def get_version(package: str) -> str:
+ """Get an installed package version.
+
+ Args:
+ package: The package name to resolve through import metadata.
+
+ Returns:
+ str: The installed package version, or ``"unknown"`` when the package cannot be found.
+ """
+ try:
+ return version(package)
+ except PackageNotFoundError:
+ return "unknown"
+
+
+def resolve_backend(
+ backend_name: Union[str, type[CeluneBackend], CeluneBackend],
+ log: Optional[Callable[[str, str], None]] = None,
+ **backend_kwargs,
+) -> CeluneBackend:
+ """Resolve a backend specification into a backend instance.
+
+ Args:
+ backend_name: A backend name, backend class, or backend instance.
+ log: Optional log callback to expose during backend construction.
+ backend_kwargs: Backend-specific constructor options.
+
+ Returns:
+ CeluneBackend: The resolved backend instance.
+
+ Raises:
+ ValueError: The named backend is unknown.
+ TypeError: The backend specification is not a supported type.
+ """
+ log = log or _default_log
+
+ if isinstance(backend_name, CeluneBackend):
+ return backend_name
+
+ if isinstance(backend_name, type) and issubclass(backend_name, CeluneBackend):
+ return backend_name(log=log, **backend_kwargs)
+
+ if isinstance(backend_name, str):
+ key = backend_name.strip().lower()
+
+ try:
+ module_name, class_name = BACKENDS[key]
+ except KeyError as e:
+ raise ValueError(
+ string(
+ "celune.unknown_backend",
+ backend=backend_name,
+ available=", ".join(BACKENDS.keys()),
+ )
+ ) from e
+
+ module = import_module(module_name)
+ backend_cls = getattr(module, class_name)
+ return backend_cls(log=log, **backend_kwargs)
+
+ raise TypeError(string("celune.invalid_backend_type"))
diff --git a/celune/backends/base.py b/celune/backends/tts/base.py
similarity index 71%
rename from celune/backends/base.py
rename to celune/backends/tts/base.py
index 9b3c6aa..f496b47 100644
--- a/celune/backends/base.py
+++ b/celune/backends/tts/base.py
@@ -6,27 +6,36 @@
import glob
import random
import secrets
+import hashlib
import threading
import contextlib
-import hashlib
+import unittest.mock
from pathlib import Path
from abc import ABC, abstractmethod
-from collections.abc import Iterator, Generator
-from typing import Callable, Optional, Mapping, Generic
+from collections.abc import Generator, Hashable, Iterator
+from typing import (
+ Callable,
+ Generic,
+ Mapping,
+ Optional,
+ Protocol,
+ TypeAlias,
+ Union,
+ cast,
+)
import torch
import numpy as np
import numpy.typing as npt
import soundfile as sf
from huggingface_hub import snapshot_download
-from huggingface_hub.constants import HF_HUB_CACHE
-from ..utils import discard
-from ..constants import N_A_NUMERIC
-from ..cevoice import default_loader
-from ..exceptions import BackendError
-from ..paths import temp_data_dir
-from ..typing.backends import BackendModel, ModelT
+from ...utils import discard
+from ...constants import N_A_NUMERIC
+from ...cevoice import default_loader
+from ...exceptions import BackendError
+from ...typing.backends import BackendModel, ModelT
+from ...paths import huggingface_hub_cache_dir, temp_data_dir
__all__ = [
"BackendModel",
@@ -38,6 +47,67 @@
_HF_HUB_OFFLINE_LOCK = threading.Lock()
_MAX_REFERENCE_SECONDS = 10.0
+_RUNTIME_PRIMITIVE_TYPES = (str, bytes, bytearray, int, float, bool, type(None))
+
+
+class _SupportsCloseHook(Protocol):
+ """Protocol for runtime objects exposing a close hook."""
+
+ def close(self) -> None:
+ """Release runtime resources."""
+
+
+class _SupportsUnloadHook(Protocol):
+ """Protocol for runtime objects exposing an unload hook."""
+
+ def unload(self) -> None:
+ """Unload runtime state."""
+
+
+class _SupportsRuntimeAttributes(Protocol):
+ """Protocol for runtime objects that keep nested state in ``__dict__``."""
+
+ __dict__: dict[str, "RuntimeValue"]
+
+
+RuntimeValue: TypeAlias = Union[
+ str,
+ bytes,
+ bytearray,
+ int,
+ float,
+ bool,
+ None,
+ dict[Hashable, "RuntimeValue"],
+ list["RuntimeValue"],
+ set["RuntimeValue"],
+ tuple["RuntimeValue", ...],
+ _SupportsCloseHook,
+ _SupportsUnloadHook,
+ _SupportsRuntimeAttributes,
+ unittest.mock.NonCallableMock,
+]
+
+
+def _call_runtime_hook_if_present(value: RuntimeValue, name: str) -> bool:
+ """Call one release hook only when it already exists on the runtime object."""
+ if hasattr(value, "__dict__"):
+ with contextlib.suppress(TypeError):
+ existing = cast(dict[str, RuntimeValue], value.__dict__).get(name)
+ if callable(existing):
+ with contextlib.suppress(Exception):
+ existing()
+ return True
+
+ if isinstance(value, unittest.mock.NonCallableMock):
+ return False
+
+ hook = getattr(value, name, None)
+ if callable(hook):
+ with contextlib.suppress(Exception):
+ hook()
+ return True
+ return False
def cached_hf_snapshot_path(
@@ -52,7 +122,10 @@ def cached_hf_snapshot_path(
Returns:
tuple[bool, Optional[str]]: Whether there is a usable cache path for the model, and its location.
"""
- model_dir = os.path.join(HF_HUB_CACHE, f"models--{model.replace('/', '--')}")
+ model_dir = os.path.join(
+ str(huggingface_hub_cache_dir()),
+ f"models--{model.replace('/', '--')}",
+ )
refs_main = os.path.join(model_dir, "refs", "main")
snapshot_dir = os.path.join(model_dir, "snapshots")
@@ -100,6 +173,73 @@ def local_hf_offline_mode(enabled: bool = True) -> Generator[None, None, None]:
os.environ["HF_HUB_OFFLINE"] = previous_offline
+def _release_runtime_container_members(value: RuntimeValue, seen: set[int]) -> None:
+ """Recursively release nested runtime members held in common containers."""
+ if isinstance(value, dict):
+ for nested in list(value.values()):
+ _release_runtime_references(nested, seen)
+ with contextlib.suppress(Exception):
+ value.clear()
+ return
+
+ if isinstance(value, list):
+ for nested in list(value):
+ _release_runtime_references(nested, seen)
+ with contextlib.suppress(Exception):
+ value.clear()
+ return
+
+ if isinstance(value, set):
+ for nested in list(value):
+ _release_runtime_references(nested, seen)
+ with contextlib.suppress(Exception):
+ value.clear()
+ return
+
+ if isinstance(value, tuple):
+ for nested in value:
+ _release_runtime_references(nested, seen)
+
+
+def _release_runtime_object_members(value: RuntimeValue, seen: set[int]) -> None:
+ """Recursively release nested runtime members held on one object instance."""
+ if not hasattr(value, "__dict__"):
+ return
+
+ with contextlib.suppress(TypeError):
+ members = list(cast(dict[str, RuntimeValue], value.__dict__).items())
+ for attr_name, attr_value in members:
+ if attr_value is value:
+ continue
+ _release_runtime_references(attr_value, seen)
+ if attr_name in {"close", "unload"}:
+ continue
+ with contextlib.suppress(Exception):
+ setattr(value, attr_name, None)
+
+
+def _release_runtime_references(value: RuntimeValue, seen: set[int]) -> None:
+ """Recursively release nested references on an about-to-be-discarded runtime object."""
+ if isinstance(value, _RUNTIME_PRIMITIVE_TYPES):
+ return
+
+ value_id = id(value)
+ if value_id in seen:
+ return
+ seen.add(value_id)
+
+ if _call_runtime_hook_if_present(value, "close"):
+ return
+ if _call_runtime_hook_if_present(value, "unload"):
+ return
+
+ if isinstance(value, unittest.mock.NonCallableMock):
+ return
+
+ _release_runtime_container_members(value, seen)
+ _release_runtime_object_members(value, seen)
+
+
class CeluneBackend(ABC, Generic[ModelT]):
"""Base class for Celune speech backends."""
@@ -110,6 +250,7 @@ class CeluneBackend(ABC, Generic[ModelT]):
default_voice: Optional[str] = None
uses_voice_bundles: bool = False
max_new_tokens: int = 512
+ is_fake: bool = False
def __init__(
self, log: Callable[[str, str], None], model_name: Optional[str] = None
@@ -318,6 +459,9 @@ def unload_model(self) -> None:
if callable(unload):
with contextlib.suppress(Exception):
unload()
+ seen = {id(model)}
+ _release_runtime_container_members(model, seen)
+ _release_runtime_object_members(model, seen)
gc.collect()
if torch.cuda.is_available():
diff --git a/celune/backends/dotstts.py b/celune/backends/tts/dotstts.py
similarity index 93%
rename from celune/backends/dotstts.py
rename to celune/backends/tts/dotstts.py
index 30c5371..82ec285 100644
--- a/celune/backends/dotstts.py
+++ b/celune/backends/tts/dotstts.py
@@ -3,6 +3,7 @@
import os
import contextlib
+import time
from collections.abc import Iterator
from typing import Callable, Optional, Mapping, Generator, Protocol, cast
@@ -12,9 +13,9 @@
import numpy.typing as npt
from dots_tts.runtime import DotsTtsRuntime
-from ..utils import custom_assert
-from ..exceptions import BackendError
-from ..cevoice import default_loader, CEVoiceLoader
+from ...utils import custom_assert, discard
+from ...cevoice import default_loader, CEVoiceLoader
+from ...i18n import string
from .base import CeluneBackend, cached_hf_snapshot_path, local_hf_offline_mode
@@ -94,9 +95,11 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]:
loader = default_loader()
custom_assert(
loader is not None,
- BackendError(
- "backend 'dotstts' requires a compatible CEVOICE/CECHAR package "
- "with at least one valid voice identifier"
+ FileNotFoundError(
+ string(
+ "celune.compatible_bundle_required",
+ backend="dotstts",
+ )
),
)
assert loader is not None
@@ -114,9 +117,11 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]:
)
custom_assert(
bool(voice_names),
- BackendError(
- "backend 'dotstts' requires a compatible CEVOICE/CECHAR package "
- "with at least one valid voice identifier"
+ FileNotFoundError(
+ string(
+ "celune.compatible_bundle_required",
+ backend="dotstts",
+ )
),
)
assert bool(voice_names)
@@ -213,7 +218,7 @@ def model_is_available_locally(
Returns:
tuple[bool, Optional[str]]: A cache availability flag and the resolved snapshot path when present.
"""
- del lang
+ discard(lang)
return cached_hf_snapshot_path(
model,
[
@@ -330,8 +335,11 @@ def generate_stream(
total_steps = 0
pending_audio: Optional[npt.NDArray[np.float32]] = None
pending_steps = 0
+ first_chunk_time: Optional[float] = None
for chunk in stream:
+ if first_chunk_time is None:
+ first_chunk_time = time.monotonic()
batch.append(self._to_numpy_audio(chunk))
if len(batch) < chunk_size:
continue
@@ -346,6 +354,7 @@ def generate_stream(
"chunk_index": chunk_index,
"chunk_steps": pending_steps,
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": False,
},
)
@@ -366,6 +375,7 @@ def generate_stream(
"chunk_index": chunk_index,
"chunk_steps": pending_steps,
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": False,
},
)
@@ -380,6 +390,7 @@ def generate_stream(
"chunk_index": chunk_index,
"chunk_steps": len(batch),
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": True,
},
)
@@ -393,6 +404,7 @@ def generate_stream(
"chunk_index": chunk_index,
"chunk_steps": pending_steps,
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": True,
},
)
diff --git a/celune/backends/mini.py b/celune/backends/tts/mini.py
similarity index 94%
rename from celune/backends/mini.py
rename to celune/backends/tts/mini.py
index d87da1a..9b199b8 100644
--- a/celune/backends/mini.py
+++ b/celune/backends/tts/mini.py
@@ -3,9 +3,10 @@
import tempfile
import contextlib
+import time
from pathlib import Path
-from collections.abc import Iterator, Mapping
from typing import Callable, Optional, cast
+from collections.abc import Iterator, Mapping
import yaml
import numpy as np
@@ -13,11 +14,10 @@
from pocket_tts import TTSModel
from huggingface_hub import snapshot_download
-from ..paths import temp_data_dir
-from ..utils import custom_assert
-from ..exceptions import BackendError
-from ..cevoice import default_loader, CEVoiceLoader
-from ..typing.backends import MiniModel, MiniPromptState
+from ...paths import temp_data_dir
+from ...utils import custom_assert
+from ...cevoice import default_loader, CEVoiceLoader
+from ...typing.backends import MiniModel, MiniPromptState
from .base import CeluneBackend, cached_hf_snapshot_path
@@ -44,12 +44,13 @@ def __init__(self, log: Callable[[str, str], None]) -> None:
self._generated_config_path: Optional[Path] = None
self._loaded_language = "en"
- def _require_compatible_bundle(self) -> tuple[CEVoiceLoader, tuple[str, ...]]:
+ @staticmethod
+ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]:
"""Return the active CEVOICE/CECHAR loader and its usable voice names."""
loader = default_loader()
custom_assert(
loader is not None,
- BackendError(
+ FileNotFoundError(
"backend 'mini' requires a compatible CEVOICE/CECHAR package "
"with at least one valid voice identifier"
),
@@ -69,7 +70,7 @@ def _require_compatible_bundle(self) -> tuple[CEVoiceLoader, tuple[str, ...]]:
)
custom_assert(
bool(voice_names),
- BackendError(
+ FileNotFoundError(
"backend 'mini' requires a compatible CEVOICE/CECHAR package "
"with at least one valid voice identifier"
),
@@ -176,11 +177,11 @@ def _resolve_snapshot_language_dir(
return language_dir
if not languages_dir.is_dir():
- raise BackendError(
+ raise FileNotFoundError(
"invalid Pocket TTS snapshot: languages directory not found"
)
available = ", ".join(sorted(path.name for path in languages_dir.iterdir()))
- raise BackendError(
+ raise FileNotFoundError(
f"invalid Pocket TTS snapshot: languages/{language_name} not found"
+ (f" (available: {available})" if available else "")
)
@@ -207,7 +208,7 @@ def _resolve_template_config_path(self, lang: str = "en") -> Path:
if code_matches:
return code_matches[0]
- raise BackendError(
+ raise FileNotFoundError(
f"invalid Pocket TTS snapshot: template config for {language_name} not found"
)
@@ -226,11 +227,11 @@ def _build_generated_config_path(
tokenizer_path = language_dir / "tokenizer.model"
if not model_path.exists():
- raise BackendError(
+ raise FileNotFoundError(
f"invalid Pocket TTS snapshot: {model_path.relative_to(snapshot_path)} not found"
)
if not tokenizer_path.exists():
- raise BackendError(
+ raise FileNotFoundError(
f"invalid Pocket TTS snapshot: {tokenizer_path.relative_to(snapshot_path)} not found"
)
@@ -380,8 +381,11 @@ def generate_stream(
pending_steps = 0
chunk_index = 0
total_steps = 0
+ first_chunk_time: Optional[float] = None
for chunk in mini_model.generate_audio_stream(voice_state, text):
+ if first_chunk_time is None:
+ first_chunk_time = time.monotonic()
chunk_array = chunk.detach().cpu().float().numpy()
batch.append(chunk_array)
@@ -398,6 +402,7 @@ def generate_stream(
"chunk_index": chunk_index,
"chunk_steps": pending_steps,
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": False,
},
)
@@ -418,6 +423,7 @@ def generate_stream(
"chunk_index": chunk_index,
"chunk_steps": pending_steps,
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": False,
},
)
@@ -432,6 +438,7 @@ def generate_stream(
"chunk_index": chunk_index,
"chunk_steps": len(batch),
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": True,
},
)
@@ -445,6 +452,7 @@ def generate_stream(
"chunk_index": chunk_index,
"chunk_steps": pending_steps,
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": True,
},
)
diff --git a/celune/backends/qwen3.py b/celune/backends/tts/qwen3.py
similarity index 94%
rename from celune/backends/qwen3.py
rename to celune/backends/tts/qwen3.py
index 346b7d3..05ef462 100644
--- a/celune/backends/qwen3.py
+++ b/celune/backends/tts/qwen3.py
@@ -2,6 +2,7 @@
"""Qwen3 backend implementation for Celune."""
import contextlib
+import time
from collections.abc import Iterator
from typing import Callable, Optional
@@ -9,9 +10,8 @@
import numpy.typing as npt
from faster_qwen3_tts import FasterQwen3TTS, __version__ as qwen3_ver
-from ..utils import custom_assert
-from ..exceptions import BackendError
-from ..cevoice import default_loader, CEVoiceLoader
+from ...utils import custom_assert
+from ...cevoice import default_loader, CEVoiceLoader
from .base import CeluneBackend, cached_hf_snapshot_path, local_hf_offline_mode
@@ -63,7 +63,7 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]:
loader = default_loader()
custom_assert(
loader is not None,
- BackendError(
+ FileNotFoundError(
"backend 'qwen3' requires a compatible CEVOICE/CECHAR package "
"with at least one valid voice identifier"
),
@@ -83,7 +83,7 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]:
)
custom_assert(
bool(voice_names),
- BackendError(
+ FileNotFoundError(
"backend 'qwen3' requires a compatible CEVOICE/CECHAR package "
"with at least one valid voice identifier"
),
@@ -229,6 +229,7 @@ def generate_stream(
) from e
stream = None
+ first_chunk_time: Optional[float] = None
try:
stream = model.generate_voice_clone_streaming(
ref_audio=ref_wav,
@@ -240,11 +241,16 @@ def generate_stream(
for chunk in stream:
audio_chunk, sample_rate, timing = chunk
+ if first_chunk_time is None:
+ first_chunk_time = time.monotonic()
if timing is not None:
timing = dict(timing)
total_steps = timing.get("total_steps_so_far")
if timing.get("is_final") and isinstance(total_steps, int):
timing["missing_eos"] = total_steps >= self.max_new_tokens
+ timing.setdefault("first_chunk_time", first_chunk_time)
+ else:
+ timing = {"first_chunk_time": first_chunk_time}
yield audio_chunk, sample_rate, timing
finally:
if stream is not None and hasattr(stream, "close"):
diff --git a/celune/backends/voxcpm2.py b/celune/backends/tts/voxcpm2.py
similarity index 87%
rename from celune/backends/voxcpm2.py
rename to celune/backends/tts/voxcpm2.py
index 36a7ca9..c4ffd8a 100644
--- a/celune/backends/voxcpm2.py
+++ b/celune/backends/tts/voxcpm2.py
@@ -3,19 +3,19 @@
import os
import contextlib
+import time
from collections.abc import Iterator
from typing import Callable, Optional, Mapping, Generator
-import torch
import numpy as np
import numpy.typing as npt
from voxcpm import VoxCPM
from . import get_version
-from ..constants import BASE_SR
-from ..utils import custom_assert
-from ..exceptions import BackendError
-from ..cevoice import default_loader, CEVoiceLoader
+from ...constants import BASE_SR
+from ...utils import custom_assert
+from ...cevoice import default_loader, CEVoiceLoader
+from ...i18n import string
from .base import CeluneBackend, cached_hf_snapshot_path, local_hf_offline_mode
@@ -87,9 +87,11 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]:
loader = default_loader()
custom_assert(
loader is not None,
- BackendError(
- "backend 'voxcpm2' requires a compatible CEVOICE/CECHAR package "
- "with at least one valid voice identifier"
+ FileNotFoundError(
+ string(
+ "celune.compatible_bundle_required",
+ backend="voxcpm2",
+ )
),
)
assert loader is not None
@@ -107,9 +109,11 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]:
)
custom_assert(
bool(voice_names),
- BackendError(
- "backend 'voxcpm2' requires a compatible CEVOICE/CECHAR package "
- "with at least one valid voice identifier"
+ FileNotFoundError(
+ string(
+ "celune.compatible_bundle_required",
+ backend="voxcpm2",
+ )
),
)
assert bool(voice_names)
@@ -195,8 +199,22 @@ def load_model(self, model_id: str, **kwargs) -> VoxCPM:
"""
available, path = self.model_is_available_locally(model_id)
- torch.backends.cudnn.deterministic = True
- torch.use_deterministic_algorithms(True)
+ # NOTE:
+ # this may cause errors in internal ops when switching backends
+ # where one backend ran with deterministic algorithms, others without them,
+ # which can cause errors such as:
+ #
+ # RuntimeError: _unsafe_index found unexpected index type Float
+ #
+ # while switching in order from: voxcpm2 -> dotstts -> qwen3,
+ # which is then trapped in Celune's warmup failure except block, and may potentially
+ # leave the runtime in a buggy state, or even trigger fatal errors
+ #
+ # please do not modulate deterministic algorithm state in PyTorch on a per-backend basis
+
+ # import torch
+ # torch.backends.cudnn.deterministic = True
+ # torch.use_deterministic_algorithms(True)
if available and path is not None:
with local_hf_offline_mode():
@@ -296,8 +314,11 @@ def generate_stream(
pending_steps = 0
chunk_index = 0
total_steps = 0
+ first_chunk_time: Optional[float] = None
for chunk in stream:
+ if first_chunk_time is None:
+ first_chunk_time = time.monotonic()
batch.append(chunk)
if len(batch) < chunks_per_batch:
@@ -313,6 +334,7 @@ def generate_stream(
"chunk_index": chunk_index,
"chunk_steps": pending_steps,
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": False,
},
)
@@ -331,8 +353,9 @@ def generate_stream(
{
"backend": self.name,
"chunk_index": chunk_index,
- "chunk_steps": len(batch),
+ "chunk_steps": pending_steps,
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": False,
},
)
@@ -347,6 +370,7 @@ def generate_stream(
"chunk_index": chunk_index,
"chunk_steps": len(batch),
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": True,
"missing_eos": total_steps >= self.max_new_tokens,
},
@@ -361,6 +385,7 @@ def generate_stream(
"chunk_index": chunk_index,
"chunk_steps": pending_steps,
"total_steps_so_far": total_steps,
+ "first_chunk_time": first_chunk_time,
"is_final": True,
"missing_eos": total_steps >= self.max_new_tokens,
},
diff --git a/celune/backends/vc/__init__.py b/celune/backends/vc/__init__.py
new file mode 100644
index 0000000..a85ebd4
--- /dev/null
+++ b/celune/backends/vc/__init__.py
@@ -0,0 +1,70 @@
+# SPDX-License-Identifier: MIT
+"""Celune voice-conversion backend initialization manager."""
+
+from importlib import import_module
+from typing import Callable, Optional, Union
+
+from .base import CeluneVCBackend
+
+__all__ = ["CeluneVCBackend", "resolve_vc_backend"]
+
+VC_BACKENDS = {
+ "passthrough": (
+ "celune.backends.vc.passthrough",
+ "CelunePassthroughVCBackend",
+ ),
+ "seed-vc": (
+ "celune.backends.vc.seedvc",
+ "CeluneSeedVCBackend",
+ ),
+}
+
+
+def _default_log(_msg: str, _severity: str = "info") -> None:
+ """Default log signature for type checking."""
+
+
+def resolve_vc_backend(
+ backend_name: Union[str, type[CeluneVCBackend], CeluneVCBackend],
+ log: Optional[Callable[[str, str], None]] = None,
+) -> CeluneVCBackend:
+ """Resolve a voice-conversion backend specification into an instance.
+
+ Args:
+ backend_name: A backend name, backend class, or backend instance.
+ log: Optional log callback to expose during backend construction.
+
+ Returns:
+ CeluneVCBackend: The resolved voice-conversion backend instance.
+
+ Raises:
+ ValueError: The named backend is unknown.
+ TypeError: The backend specification is not a supported type.
+ """
+ log = log or _default_log
+
+ if isinstance(backend_name, CeluneVCBackend):
+ return backend_name
+
+ if isinstance(backend_name, type) and issubclass(backend_name, CeluneVCBackend):
+ return backend_name(log=log)
+
+ if isinstance(backend_name, str):
+ key = backend_name.strip().lower()
+
+ try:
+ module_name, class_name = VC_BACKENDS[key]
+ except KeyError as e:
+ raise ValueError(
+ "unknown voice-conversion backend: "
+ f"'{backend_name}' (available: {', '.join(VC_BACKENDS.keys())})"
+ ) from e
+
+ module = import_module(module_name)
+ backend_cls = getattr(module, class_name)
+ return backend_cls(log=log)
+
+ raise TypeError(
+ "'backend_name' must be a voice-conversion backend instance, "
+ "voice-conversion backend type, or backend name string"
+ )
diff --git a/celune/backends/vc/base.py b/celune/backends/vc/base.py
new file mode 100644
index 0000000..ff124a9
--- /dev/null
+++ b/celune/backends/vc/base.py
@@ -0,0 +1,35 @@
+# SPDX-License-Identifier: MIT
+"""Unified voice-conversion backend abstractions for Celune."""
+
+from typing import Callable
+from abc import ABC, abstractmethod
+
+from ...dataclasses.pipeline import AudioOutput, VoiceConversionRequest
+
+__all__ = ["CeluneVCBackend"]
+
+
+class CeluneVCBackend(ABC):
+ """Base class for Celune voice-conversion backends."""
+
+ name: str = "unknown"
+ is_fake: bool = False
+
+ def __init__(self, log: Callable[[str, str], None]) -> None:
+ self.log = log
+
+ def preload_models(self) -> None:
+ """Ensure any optional backend assets are ready before conversion."""
+ return None
+
+ def unload_model(self) -> None:
+ """Release optional backend runtime state."""
+ return None
+
+ @abstractmethod
+ def convert(self, request: VoiceConversionRequest) -> AudioOutput:
+ """Convert one source performance into the target voice.
+
+ Args:
+ request: The voice-conversion request to process.
+ """
diff --git a/celune/backends/vc/passthrough.py b/celune/backends/vc/passthrough.py
new file mode 100644
index 0000000..29f47d8
--- /dev/null
+++ b/celune/backends/vc/passthrough.py
@@ -0,0 +1,30 @@
+# SPDX-License-Identifier: MIT
+"""Passthrough voice-conversion backend for plumbing and tests."""
+
+import numpy as np
+
+from .base import CeluneVCBackend
+from ...dataclasses.pipeline import AudioOutput, VoiceConversionRequest
+
+__all__ = ["CelunePassthroughVCBackend"]
+
+
+class CelunePassthroughVCBackend(CeluneVCBackend):
+ """Dummy voice-conversion backend that returns the input audio unchanged."""
+
+ name = "passthrough"
+
+ def convert(self, request: VoiceConversionRequest) -> AudioOutput:
+ """Return the source audio as playable output without modification.
+
+ Args:
+ request: The voice-conversion request to convert.
+
+ Returns:
+ AudioOutput: The unmodified source audio and sample rate.
+ """
+ return AudioOutput(
+ audio=np.asarray(request.source_audio, dtype=np.float32).copy(),
+ sample_rate=request.sample_rate,
+ label=request.label,
+ )
diff --git a/celune/backends/vc/seedvc.py b/celune/backends/vc/seedvc.py
new file mode 100644
index 0000000..edad7b2
--- /dev/null
+++ b/celune/backends/vc/seedvc.py
@@ -0,0 +1,246 @@
+# SPDX-License-Identifier: MIT
+"""Seed-VC backend for Celune voice conversion."""
+
+import gc
+import importlib
+import tempfile
+import contextlib
+import threading
+from pathlib import Path
+from types import ModuleType, TracebackType
+from typing import Optional, Callable
+from collections.abc import Generator
+
+import numpy as np
+import numpy.typing as npt
+import soundfile as sf
+
+from .base import CeluneVCBackend
+from ...dataclasses.pipeline import AudioOutput, VoiceConversionRequest
+from ...i18n import string
+from ...paths import huggingface_hub_cache_dir
+
+__all__ = ["CeluneSeedVCBackend"]
+
+
+class _TemporaryWaveFile:
+ """Temporary WAV file helper used for backends that require file paths."""
+
+ def __init__(self, audio: npt.NDArray[np.float32], sample_rate: int) -> None:
+ self.path: Optional[Path] = None
+ try:
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file:
+ self.path = Path(temp_file.name)
+ sf.write(self.path, audio, sample_rate)
+ except Exception:
+ if self.path is not None:
+ with contextlib.suppress(OSError):
+ self.path.unlink(missing_ok=True)
+ raise
+
+ def close(self) -> None:
+ """Delete the underlying temporary WAV file."""
+ if self.path is None:
+ return
+ with contextlib.suppress(OSError):
+ self.path.unlink(missing_ok=True)
+ self.path = None
+
+ def __enter__(self) -> Path:
+ assert self.path is not None
+ return self.path
+
+ def __exit__(
+ self,
+ exc_type: Optional[type[BaseException]],
+ exc: Optional[BaseException],
+ traceback: Optional[TracebackType],
+ ) -> None:
+ self.close()
+
+
+class CeluneSeedVCBackend(CeluneVCBackend):
+ """Seed-VC backend using the forked package wrapper confirmed by Celune tests."""
+
+ name = "seed-vc"
+
+ def __init__(
+ self,
+ log: Callable[[str, str], None],
+ diffusion_steps: int = 30,
+ length_adjust: float = 1.0,
+ inference_cfg_rate: float = 0.5,
+ f0_condition: bool = False,
+ auto_f0_adjust: bool = True,
+ pitch_shift: int = 0,
+ ) -> None:
+ super().__init__(log=log)
+ self.diffusion_steps = diffusion_steps
+ self.length_adjust = length_adjust
+ self.inference_cfg_rate = inference_cfg_rate
+ self.f0_condition = f0_condition
+ self.auto_f0_adjust = auto_f0_adjust
+ self.pitch_shift = pitch_shift
+ self._wrapper = None
+ self._wrapper_lock = threading.Lock()
+
+ @staticmethod
+ def _seedvc_huggingface_cache_dir(create: bool = False) -> Path:
+ """Return the shared Hugging Face cache directory used for Seed-VC assets."""
+ return huggingface_hub_cache_dir(create=create)
+
+ @classmethod
+ def _configure_seedvc_downloads(
+ cls,
+ hf_utils_module: ModuleType,
+ wrapper_module: ModuleType,
+ ) -> None:
+ """Redirect Seed-VC's hardcoded checkpoint downloads into Celune's cache."""
+ cache_dir = cls._seedvc_huggingface_cache_dir(create=True)
+ hf_hub_download = getattr(hf_utils_module, "hf_hub_download")
+
+ def load_custom_model_from_hf(
+ repo_id: str,
+ model_filename: str = "pytorch_model.bin",
+ config_filename: Optional[str] = None,
+ ):
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ model_path = hf_hub_download(
+ repo_id=repo_id,
+ filename=model_filename,
+ cache_dir=str(cache_dir),
+ )
+ if config_filename is None:
+ return model_path
+
+ config_path = hf_hub_download(
+ repo_id=repo_id,
+ filename=config_filename,
+ cache_dir=str(cache_dir),
+ )
+ return model_path, config_path
+
+ setattr(hf_utils_module, "load_custom_model_from_hf", load_custom_model_from_hf)
+ setattr(wrapper_module, "load_custom_model_from_hf", load_custom_model_from_hf)
+
+ @classmethod
+ def _load_wrapper_type(cls) -> type:
+ """Import and return the Seed-VC wrapper class."""
+ try:
+ hf_utils_module = importlib.import_module("seed_vc.hf_utils")
+ wrapper_module = importlib.import_module("seed_vc.seed_vc_wrapper")
+ except ImportError as e:
+ raise ImportError(
+ string("seedvc.package_required", package="seed_vc")
+ ) from e
+
+ cls._configure_seedvc_downloads(hf_utils_module, wrapper_module)
+ return getattr(wrapper_module, "SeedVCWrapper")
+
+ def _get_wrapper(self):
+ """Return a cached Seed-VC wrapper instance."""
+ with self._wrapper_lock:
+ if self._wrapper is None:
+ wrapper_type = self._load_wrapper_type()
+ self.log(string("seedvc.loading_models"), "info")
+ self._wrapper = wrapper_type()
+ return self._wrapper
+
+ @staticmethod
+ def _mix_to_mono(audio: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]:
+ """Return a mono float32 waveform for Seed-VC inference."""
+ mono = np.asarray(audio, dtype=np.float32)
+ if mono.ndim == 1:
+ return np.clip(mono, -1.0, 1.0)
+ if mono.ndim == 2:
+ return np.clip(np.mean(mono, axis=1, dtype=np.float32), -1.0, 1.0)
+ raise ValueError("Seed-VC expects one-dimensional or two-dimensional audio")
+
+ @staticmethod
+ def _drain_generator_return_value(
+ generator: Generator[object, None, object],
+ ) -> object:
+ """Run a generator to completion and return its final value."""
+ try:
+ while True:
+ next(generator)
+ except StopIteration as exc:
+ return exc.value
+
+ @property
+ def output_sample_rate(self) -> int:
+ """Return the sample rate produced by the backend's default Seed-VC mode.
+
+ Returns:
+ int: ``44100`` when f0 conditioning is enabled, otherwise ``22050``.
+ """
+ return 44100 if self.f0_condition else 22050
+
+ @staticmethod
+ def _resolve_f0_condition(request: VoiceConversionRequest, default: bool) -> bool:
+ """Return the effective f0 conditioning mode for one conversion request."""
+ if isinstance(request.f0_condition, bool):
+ return request.f0_condition
+ return default
+
+ def preload_models(self) -> None:
+ """Load Seed-VC eagerly so VC mode is ready before the first request."""
+ self._get_wrapper()
+
+ def unload_model(self) -> None:
+ """Release the cached Seed-VC wrapper and best-effort GPU memory."""
+ with self._wrapper_lock:
+ self._wrapper = None
+
+ gc.collect()
+ with contextlib.suppress(Exception):
+ import torch
+
+ if torch.cuda.is_available():
+ torch.cuda.synchronize()
+ torch.cuda.empty_cache()
+
+ def convert(self, request: VoiceConversionRequest) -> AudioOutput:
+ """Convert source audio into the currently selected reference voice.
+
+ Args:
+ request: Voice conversion input containing source audio and a target reference WAV path.
+
+ Returns:
+ AudioOutput: Converted audio packaged for the Celune pipeline.
+
+ Raises:
+ ValueError: The request does not include a target reference WAV.
+ """
+ if not request.target_references:
+ raise ValueError(string("seedvc.target_reference_required"))
+
+ target_reference = request.target_references[0]
+ wrapper = self._get_wrapper()
+ source_audio = self._mix_to_mono(request.source_audio)
+ f0_condition = self._resolve_f0_condition(request, self.f0_condition)
+
+ with _TemporaryWaveFile(source_audio, request.sample_rate) as source_path:
+ result = self._drain_generator_return_value(
+ wrapper.convert_voice(
+ source=str(source_path),
+ target=str(target_reference),
+ diffusion_steps=self.diffusion_steps,
+ length_adjust=self.length_adjust,
+ inference_cfg_rate=self.inference_cfg_rate,
+ f0_condition=f0_condition,
+ auto_f0_adjust=self.auto_f0_adjust,
+ pitch_shift=request.pitch_shift
+ if request.pitch_shift is not None
+ else 0,
+ stream_output=False,
+ )
+ )
+
+ audio = np.asarray(result, dtype=np.float32).reshape(-1)
+ audio = np.clip(audio, -1.0, 1.0)
+ return AudioOutput(
+ audio=audio,
+ sample_rate=44100 if f0_condition else 22050,
+ label=request.label,
+ )
diff --git a/celune/celune.py b/celune/celune.py
index 4fa4942..d79c69e 100644
--- a/celune/celune.py
+++ b/celune/celune.py
@@ -3,21 +3,21 @@
import os
import gc
+import sys
import time
import queue
+import shutil
import threading
import contextlib
from pathlib import Path
-from typing import Optional, Callable, Union, cast
+from dataclasses import dataclass
+from typing import Optional, Callable, Protocol, Union, cast
import torch
import numpy as np
import numpy.typing as npt
from transformers.modeling_utils import PreTrainedModel
-from transformers.utils.logging import disable_progress_bar
-from transformers.utils import logging as hf_logging
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
-from huggingface_hub.utils import disable_progress_bars
from . import __version__
from .dataclasses.celune import (
@@ -31,22 +31,36 @@
CeluneRuntimeState,
CeluneVoiceState,
)
+from .dataclasses.pipeline import AudioInputRequest, AudioOutput
from .dataclasses.properties import (
bind_constant_properties,
bind_forwarded_properties,
)
+from .dataclasses.events import (
+ CharacterChangedEvent,
+ CharacterLoadedEvent,
+ CharacterUnloadedEvent,
+ ReadyEvent,
+ ShutdownEvent,
+ StateChangedEvent,
+ VoiceChangedEvent,
+)
from .chroma import AudioRGBGlow
-from .backends.qwen3 import Qwen3
+from .backends.tts.qwen3 import Qwen3
from .extensions.base import CeluneContext
+from .extensions.events import EventDispatcher
from .extensions.manager import CeluneExtensionManager
from .config import Config, config_bool, config_value
-from .paths import project_root
+from .paths import project_root, app_data_dir
from .runtime import log_runtime_banner, validate_runtime
-from .backends import CeluneBackend, resolve_backend
+from .backends.tts import BACKENDS, CeluneBackend, resolve_backend
+from .backends.vc import VC_BACKENDS, CeluneVCBackend, resolve_vc_backend
from .exceptions import NotAvailableError, WarmupError, BackendError
from .modeling import normalizer_device, load_normalizer_components
from .constants import APP_NAME, JSONSerializable, NORMALIZER_MODEL_ID
+from .i18n import string
from .typing.celune import (
+ CoreBackendSpec,
CeluneStateAccessors,
Generative,
InputStateCallback,
@@ -54,8 +68,12 @@
NormalizerTokenizer,
ProgressCallback,
ReleasableObject,
+ TTSBackendSpec,
+ VCBackendSpec,
VoiceLockStateCallback,
)
+from .typing.backends import BackendModel
+from .typing.events import EventName, EventPayload
from .utils import format_number, format_error, discard, is_port_usable, custom_assert
from .vram import (
QWEN3_0_6B_MODEL,
@@ -74,11 +92,15 @@
persona_quantization,
)
from .cevoice import (
+ CEVoicePersona,
+ active_bundle_path,
announce_default_bundle,
+ bundle_matches_default_pack_checksum,
bundle_character_name,
- default_bundle_path,
default_loader,
+ is_protected_temp_path,
persona_metadata_from_manifest,
+ resolve_bundle_path,
select_voice_bundle,
)
from .pipeline import (
@@ -91,6 +113,8 @@
queue_sfx_audio,
playback_worker,
play as play_pipeline,
+ handle_audio_input,
+ convert_audio_input,
queue_speech,
release_pipeline,
say as say_pipeline,
@@ -101,6 +125,14 @@
from .typing.pipeline import SpeechStreamQueue
+class _BundleWithPath(Protocol):
+ """Protocol for bundle-like objects that expose a path."""
+
+ @property
+ def path(self) -> Union[str, Path]:
+ """Return the bundle path."""
+
+
def _config_str(value: JSONSerializable) -> Optional[str]:
"""Return a config value only when it is a string."""
return value if isinstance(value, str) else None
@@ -117,6 +149,105 @@ def _config_int(value: JSONSerializable, default: int) -> int:
raise TypeError("config value cannot be converted to int")
+def _configured_vc_pitch_shift(config: Config) -> int:
+ """Return the configured default pitch shift for VC backends."""
+ env_value = os.getenv("CELUNE_VC_PITCH_SHIFT")
+ if env_value is not None and env_value.strip():
+ return _config_int(env_value.strip(), 0)
+
+ configured_value = config_value(config, "voice_conversion_pitch_shift")
+ if configured_value is None:
+ configured_value = config_value(config, "vc_pitch_shift")
+ return _config_int(configured_value, 0)
+
+
+def _configured_vc_f0_condition(config: Config) -> bool:
+ """Return whether VC should run in Seed-VC singing mode by default."""
+ return config_bool(
+ config,
+ "CELUNE_VC_F0_CONDITION",
+ "voice_conversion_f0_condition",
+ False,
+ ) or config_bool(config, "CELUNE_VC_F0_CONDITION", "vc_f0_condition", False)
+
+
+def _resolve_input_mode(config: Config, requested_mode: Optional[str] = None) -> str:
+ """Resolve Celune's active input mode from config and optional override."""
+ candidate = requested_mode
+ if candidate is None:
+ candidate = _config_str(config_value(config, "input_mode"))
+ if candidate is None:
+ candidate = _config_str(config_value(config, "mode"))
+ if candidate is None:
+ return "text_to_speech"
+
+ normalized = candidate.strip().lower()
+ if normalized in {"text_to_speech", "tts"}:
+ return "text_to_speech"
+ if normalized in {"voice_conversion", "revoice"}:
+ return "voice_conversion"
+ raise ValueError(f"unknown input mode: '{candidate}'")
+
+
+def _core_backend_target(
+ backend_spec: CoreBackendSpec,
+ log_callback: MessageCallback,
+ input_mode: str,
+) -> tuple[str, CoreBackendSpec]:
+ """Return whether one backend specification targets TTS or VC mode."""
+ discard(log_callback)
+ if isinstance(backend_spec, CeluneVCBackend):
+ return "vc", backend_spec
+
+ if isinstance(backend_spec, CeluneBackend):
+ return "tts", backend_spec
+
+ if isinstance(backend_spec, type):
+ if issubclass(backend_spec, CeluneVCBackend):
+ return "vc", cast(VCBackendSpec, backend_spec)
+ if issubclass(backend_spec, CeluneBackend):
+ return "tts", cast(TTSBackendSpec, backend_spec)
+
+ if isinstance(backend_spec, str):
+ normalized_backend = backend_spec.strip().lower()
+ if normalized_backend in BACKENDS:
+ return "tts", cast(TTSBackendSpec, backend_spec)
+ if normalized_backend in VC_BACKENDS:
+ return "vc", cast(VCBackendSpec, backend_spec)
+
+ return (
+ ("vc", backend_spec)
+ if input_mode == "voice_conversion"
+ else ("tts", backend_spec)
+ )
+
+
+def _resolve_core_backend_specs(
+ log_callback: MessageCallback,
+ input_mode: str,
+ backend: Optional[CoreBackendSpec],
+ tts_backend: Optional[CoreBackendSpec],
+ vc_backend: Optional[CoreBackendSpec],
+) -> tuple[Optional[CoreBackendSpec], Optional[CoreBackendSpec]]:
+ """Merge the unified backend alias into the TTS and VC constructor slots."""
+ if backend is None:
+ return tts_backend, vc_backend
+
+ backend_target, resolved_backend = _core_backend_target(
+ backend,
+ log_callback,
+ input_mode,
+ )
+ if backend_target == "vc":
+ if vc_backend is not None:
+ raise BackendError("cannot specify both 'backend' and 'vc_backend'")
+ return tts_backend, resolved_backend
+
+ if tts_backend is not None:
+ raise BackendError("cannot specify both 'backend' and 'tts_backend'")
+ return resolved_backend, vc_backend
+
+
def _release_loaded_object(value: ReleasableObject) -> None:
"""Best-effort release hook for one loaded runtime object."""
close = getattr(value, "close", None)
@@ -131,15 +262,54 @@ def _release_loaded_object(value: ReleasableObject) -> None:
unload()
+_EPHEMERAL_TEMP_FILE_NAMES = frozenset(
+ {
+ "rag_prompt.txt",
+ "temporary_audio.wav",
+ }
+)
+_EPHEMERAL_TEMP_FILE_PREFIXES = ("temporary_audio.",)
+_EPHEMERAL_TEMP_DIR_PREFIXES = ("celune-cevoice-",)
+
+
class Celune(CeluneStateAccessors):
"""The character engine for Celune."""
_instance: Optional["Celune"] = None
+ @dataclass
+ class _ReloadSnapshot:
+ """Rollback state captured before a backend or CEVOICE hot reload."""
+
+ backend: CeluneBackend
+ restorable_backend_spec: Union[str, type[CeluneBackend]]
+ backend_spec: Optional[Union[str, type[CeluneBackend]]]
+ backend_kwargs: dict[str, JSONSerializable]
+ vc_backend: Optional[CeluneVCBackend]
+ restorable_vc_backend_spec: Optional[Union[str, type[CeluneVCBackend]]]
+ vc_backend_spec: Optional[Union[str, type[CeluneVCBackend]]]
+ voice_conversion_backend: str
+ tts_backend: str
+ input_mode: str
+ model: Optional[BackendModel]
+ model_name: str
+ voices: tuple[str, ...]
+ current_voice: Optional[str]
+ current_character: Optional[str]
+ current_character_persona: Optional[CEVoicePersona]
+ voice_bundle_is_default: bool
+ loaded: bool
+ cur_state: str
+
def __init__(
self,
config: Config,
- tts_backend: Optional[Union[str, CeluneBackend, type[CeluneBackend]]] = None,
+ backend: Optional[CoreBackendSpec] = None,
+ tts_backend: Optional[CoreBackendSpec] = None,
+ vc_backend: Optional[CoreBackendSpec] = None,
+ vc_pitch_shift: Optional[int] = None,
+ vc_f0_condition: Optional[bool] = None,
+ input_mode: Optional[str] = None,
chunk_size: int = 0, # defaulted to 0 because not all backends use this
target_chunk_length: float = 0.64,
language: str = "Auto", # Qwen3 backend accepts a language, others may not
@@ -172,6 +342,7 @@ def __init__(
),
progress_callback=(progress_callback or self._noop_progress),
)
+ self._event_dispatcher = EventDispatcher(log_warning=self.log, dev=dev)
self._backend_state = CeluneBackendState(config=config)
self._model_state = CeluneModelState()
@@ -183,6 +354,24 @@ def __init__(
self._pipeline_state.playback_done.set()
self.config = config
+ self.input_mode = _resolve_input_mode(config, input_mode)
+ tts_backend, vc_backend = _resolve_core_backend_specs(
+ self.log_callback,
+ self.input_mode,
+ backend,
+ tts_backend,
+ vc_backend,
+ )
+ self.vc_pitch_shift = (
+ _configured_vc_pitch_shift(config)
+ if vc_pitch_shift is None
+ else _config_int(vc_pitch_shift, 0)
+ )
+ self.vc_f0_condition = (
+ _configured_vc_f0_condition(config)
+ if vc_f0_condition is None
+ else vc_f0_condition
+ )
select_voice_bundle(_config_str(config_value(config, "voice_bundle")))
preset = resolve_vram_preset(config)
@@ -226,11 +415,15 @@ def __init__(
backend_kwargs["clone_model_id"] = preset.qwen3_clone_model_id
try:
+ resolved_tts_backend = cast(TTSBackendSpec, tts_backend)
if not isinstance(tts_backend, CeluneBackend):
- self._backend_spec = tts_backend
+ self._backend_spec = cast(
+ Union[str, type[CeluneBackend]],
+ resolved_tts_backend,
+ )
self._backend_kwargs = dict(backend_kwargs)
self.backend = resolve_backend(
- tts_backend,
+ resolved_tts_backend,
log=self.log_callback,
**backend_kwargs,
)
@@ -244,9 +437,55 @@ def __init__(
raise BackendError(
f"backend '{tts_backend}' has unmet dependencies: '{e.name}'"
) from e
+ except FileNotFoundError as e:
+ raise BackendError(
+ "you must install at least one valid CEVOICE/CECHAR package"
+ ) from e
except Exception as e:
raise BackendError(f"internal backend error: {format_error(e, dev)}") from e
+ if vc_backend is None and self.input_mode == "voice_conversion":
+ vc_backend = _config_str(
+ config_value(config, "voice_conversion_backend")
+ ) or _config_str(config_value(config, "vc_backend"))
+ if vc_backend is None:
+ vc_backend = "passthrough"
+
+ try:
+ if vc_backend is not None:
+ resolved_vc_backend = cast(VCBackendSpec, vc_backend)
+ if not isinstance(vc_backend, CeluneVCBackend):
+ self._vc_backend_spec = cast(
+ Union[str, type[CeluneVCBackend]],
+ resolved_vc_backend,
+ )
+ self.vc_backend = resolve_vc_backend(
+ resolved_vc_backend,
+ log=self.log_callback,
+ )
+ if hasattr(self.vc_backend, "pitch_shift"):
+ setattr(self.vc_backend, "pitch_shift", self.vc_pitch_shift)
+ if hasattr(self.vc_backend, "f0_condition"):
+ setattr(self.vc_backend, "f0_condition", self.vc_f0_condition)
+ self.voice_conversion_backend = self.vc_backend.name
+ else:
+ self.vc_backend = None
+ self.voice_conversion_backend = ""
+ except ValueError as e:
+ raise BackendError(str(e)) from e
+ except TypeError as e:
+ raise BackendError(
+ f"invalid voice-conversion backend specification: '{vc_backend}'"
+ ) from e
+ except ModuleNotFoundError as e:
+ raise BackendError(
+ f"voice-conversion backend '{vc_backend}' has unmet dependencies: '{e.name}'"
+ ) from e
+ except Exception as e:
+ raise BackendError(
+ f"internal voice-conversion backend error: {format_error(e, dev)}"
+ ) from e
+
if chunk_size:
self.chunk_size = chunk_size
else:
@@ -298,6 +537,35 @@ def __init__(
bind_forwarded_properties(locals(), CELUNE_FORWARDED_PROPERTIES)
bind_constant_properties(locals(), CELUNE_CONSTANT_PROPERTIES)
+ @property
+ def cur_state(self) -> str:
+ """Return Celune's current runtime state.
+
+ Returns:
+ str: The current runtime-state label.
+ """
+ return self._runtime_state.cur_state
+
+ @cur_state.setter
+ def cur_state(self, value: str) -> None:
+ """Store Celune's runtime state and emit transition events on change.
+
+ Args:
+ value: The new runtime-state label to store.
+ """
+ old_state = self._runtime_state.cur_state
+ self._runtime_state.cur_state = value
+ if old_state == value:
+ return
+ self._emit_event(
+ "state_changed",
+ StateChangedEvent(
+ celune=self,
+ old_state=old_state,
+ new_state=value,
+ ),
+ )
+
@staticmethod
def _noop_message(msg: str, severity: str = "info") -> None:
"""Discard a message callback."""
@@ -335,6 +603,113 @@ def wrapped_fatal() -> None:
self.glow.fatal = wrapped_fatal
setattr(self.glow, "_celune_fatal_wrapped", True)
+ def _emit_event(self, event_name: EventName, event: EventPayload) -> None:
+ """Dispatch one typed event through Celune's internal event bus."""
+ self._event_dispatcher.emit(event_name, event)
+
+ @staticmethod
+ def _is_ephemeral_temp_path(path: Path) -> bool:
+ """Return whether one temp path is safe for residual cleanup."""
+ if path.is_dir():
+ return path.name.startswith(_EPHEMERAL_TEMP_DIR_PREFIXES)
+
+ if path.name in _EPHEMERAL_TEMP_FILE_NAMES:
+ return True
+
+ return path.name.startswith(_EPHEMERAL_TEMP_FILE_PREFIXES)
+
+ def _cleanup_residual_temp_data(self, temp_dir: Path) -> None:
+ """Delete only Celune's disposable residual temp artifacts."""
+ if not temp_dir.is_dir():
+ return
+
+ disposable_paths = [
+ path
+ for path in temp_dir.iterdir()
+ if self._is_ephemeral_temp_path(path) and not is_protected_temp_path(path)
+ ]
+ trailing_files = len(disposable_paths)
+
+ if trailing_files <= 0:
+ return
+
+ if trailing_files == 1:
+ self.log(
+ string("celune.residual_temp_item", app_name=APP_NAME),
+ "warning",
+ )
+ else:
+ self.log(
+ string(
+ "celune.residual_temp_items",
+ app_name=APP_NAME,
+ count=trailing_files,
+ ),
+ "warning",
+ )
+ self.log(string("celune.deleting"), "warning")
+
+ with contextlib.suppress(OSError):
+ for path in disposable_paths:
+ if path.is_dir():
+ shutil.rmtree(path)
+ else:
+ path.unlink(missing_ok=True)
+
+ @staticmethod
+ def _bundle_path_string(bundle: Optional[_BundleWithPath]) -> Optional[str]:
+ """Return one bundle path as a string when it is available."""
+ if bundle is None:
+ return None
+ return str(bundle.path)
+
+ def _emit_character_event_transition(
+ self,
+ old_character: Optional[str],
+ old_bundle_path: Optional[str],
+ new_character: Optional[str],
+ new_bundle_path: Optional[str],
+ new_is_default: bool,
+ ) -> None:
+ """Emit the appropriate character lifecycle event for one bundle transition."""
+ if old_character and new_character:
+ if old_character == new_character and old_bundle_path == new_bundle_path:
+ return
+ self._emit_event(
+ "character_changed",
+ CharacterChangedEvent(
+ celune=self,
+ old_character=old_character,
+ new_character=new_character,
+ old_bundle_path=old_bundle_path,
+ new_bundle_path=new_bundle_path,
+ new_is_default=new_is_default,
+ ),
+ )
+ return
+
+ if new_character:
+ self._emit_event(
+ "character_loaded",
+ CharacterLoadedEvent(
+ celune=self,
+ character_name=new_character,
+ bundle_path=new_bundle_path,
+ is_default=new_is_default,
+ ),
+ )
+ return
+
+ if old_character:
+ self._emit_event(
+ "character_unloaded",
+ CharacterUnloadedEvent(
+ celune=self,
+ character_name=old_character,
+ bundle_path=old_bundle_path,
+ ),
+ )
+
@staticmethod
def _validate_backend_against_preset(
backend: CeluneBackend,
@@ -350,6 +725,26 @@ def _validate_backend_against_preset(
f"'{backend.clone_model_id}' for VRAM tier '{preset.tier}'"
)
+ def is_voice_conversion_mode(self) -> bool:
+ """Public interface for Celune._is_voice_conversion_mode.
+
+ Returns:
+ bool: Whether this Celune instance is currently in voice conversion mode.
+ """
+ return self._is_voice_conversion_mode()
+
+ def _is_voice_conversion_mode(self) -> bool:
+ """Return whether Celune is currently running in voice-conversion mode."""
+ return self.input_mode == "voice_conversion" or isinstance(
+ self.vc_backend, CeluneVCBackend
+ )
+
+ def _active_runtime_backend_name(self) -> str:
+ """Return the backend name that should represent the active speech runtime."""
+ if self._is_voice_conversion_mode() and self.vc_backend is not None:
+ return self.vc_backend.name
+ return self.backend.name
+
@staticmethod
def _clear_queue(q: queue.Queue) -> None:
"""Drain all pending items from a queue."""
@@ -362,7 +757,7 @@ def _persona_conn(self) -> Optional[PersonaClient]:
return None
if not persona_is_available():
- self.log("Persona could not be initialized.", "warning")
+ self.log(string("celune.persona_init_failed"), "warning")
return None
return create_persona_client(self.config, log_dev=self.log_dev)
@@ -400,6 +795,8 @@ def unload_runtime_state(self, include_normalizer: bool = False) -> None:
discard(self, "model")
self.backend.unload_model()
+ if self.vc_backend is not None:
+ self.vc_backend.unload_model()
if include_normalizer:
self._unload_normalizer_components()
@@ -425,6 +822,558 @@ def _recreate_tts_backend(self) -> bool:
self.tts_backend = self.backend.name
return True
+ def _recreate_vc_backend(self) -> bool:
+ """Rebuild the VC backend from its original constructor recipe."""
+ if self._vc_backend_spec is None:
+ return False
+
+ self.vc_backend = resolve_vc_backend(
+ self._vc_backend_spec,
+ log=self.log_callback,
+ )
+ if hasattr(self.vc_backend, "pitch_shift"):
+ setattr(self.vc_backend, "pitch_shift", self.vc_pitch_shift)
+ if hasattr(self.vc_backend, "f0_condition"):
+ setattr(self.vc_backend, "f0_condition", self.vc_f0_condition)
+ self.voice_conversion_backend = self.vc_backend.name
+ return True
+
+ def _restorable_vc_backend_spec(
+ self,
+ ) -> Optional[Union[str, type[CeluneVCBackend]]]:
+ """Return a VC backend specification that does not pin the current instance."""
+ if self.vc_backend is None:
+ return None
+ if self._vc_backend_spec is not None:
+ return self._vc_backend_spec
+ return type(self.vc_backend)
+
+ def _restorable_active_backend_spec(
+ self,
+ ) -> Union[str, type[CeluneBackend], type[CeluneVCBackend]]:
+ """Return a backend specification for whichever backend family is active."""
+ if self._is_voice_conversion_mode() and self.vc_backend is not None:
+ restorable_vc_backend = self._restorable_vc_backend_spec()
+ if restorable_vc_backend is not None:
+ return restorable_vc_backend
+ return self._restorable_backend_spec()
+
+ def _backend_reload_kwargs(
+ self,
+ backend_spec: Union[str, CeluneBackend, type[CeluneBackend]],
+ ) -> dict[str, JSONSerializable]:
+ """Return constructor kwargs needed to instantiate one backend specification."""
+ backend_kwargs: dict[str, JSONSerializable] = {}
+ if isinstance(backend_spec, CeluneBackend):
+ return backend_kwargs
+
+ if (
+ isinstance(backend_spec, str) and backend_spec.strip().lower() == "qwen3"
+ ) or (isinstance(backend_spec, type) and issubclass(backend_spec, Qwen3)):
+ preset = resolve_vram_preset(self.config)
+ backend_kwargs["x_vector_only"] = config_bool(
+ self.config,
+ "CELUNE_QWEN3_X_VECTOR_ONLY",
+ "qwen3_x_vector_only",
+ )
+ backend_kwargs["clone_model_id"] = preset.qwen3_clone_model_id
+ return backend_kwargs
+
+ def _capture_reload_snapshot(self) -> _ReloadSnapshot:
+ """Capture the current backend runtime state for rollback."""
+ return self._ReloadSnapshot(
+ backend=self.backend,
+ restorable_backend_spec=self._restorable_backend_spec(),
+ backend_spec=self._backend_spec,
+ backend_kwargs=dict(self._backend_kwargs),
+ vc_backend=self.vc_backend,
+ restorable_vc_backend_spec=self._restorable_vc_backend_spec(),
+ vc_backend_spec=self._vc_backend_spec,
+ voice_conversion_backend=self.voice_conversion_backend,
+ tts_backend=self.tts_backend,
+ input_mode=self.input_mode,
+ model=self.model,
+ model_name=self.model_name,
+ voices=self.voices,
+ current_voice=self.current_voice,
+ current_character=self.current_character,
+ current_character_persona=self.current_character_persona,
+ voice_bundle_is_default=self.voice_bundle_is_default,
+ loaded=self.loaded,
+ cur_state=self.cur_state,
+ )
+
+ def _release_reload_snapshot(self, snapshot: _ReloadSnapshot) -> None:
+ """Drop snapshot references after a successful hot reload."""
+ snapshot.model = None
+ snapshot.backend = self.backend
+ snapshot.vc_backend = self.vc_backend
+ snapshot.current_character_persona = None
+
+ def _restorable_backend_spec(self) -> Union[str, type[CeluneBackend]]:
+ """Return a backend specification that does not pin the current instance."""
+ if self._backend_spec is not None:
+ return self._backend_spec
+ return type(self.backend)
+
+ def _restore_reload_snapshot(self, snapshot: _ReloadSnapshot) -> None:
+ """Restore backend runtime state after a failed hot reload."""
+ self.backend = snapshot.backend
+ self._backend_spec = snapshot.backend_spec
+ self._backend_kwargs = dict(snapshot.backend_kwargs)
+ self.vc_backend = snapshot.vc_backend
+ self._vc_backend_spec = snapshot.vc_backend_spec
+ self.voice_conversion_backend = snapshot.voice_conversion_backend
+ self.tts_backend = snapshot.tts_backend
+ self.input_mode = snapshot.input_mode
+ self.model = cast(Optional[PreTrainedModel], snapshot.model)
+ self.backend.model = snapshot.model
+ self.model_name = snapshot.model_name
+ self.voices = snapshot.voices
+ self.current_voice = snapshot.current_voice
+ self.current_character = snapshot.current_character
+ self.current_character_persona = snapshot.current_character_persona
+ self.voice_bundle_is_default = snapshot.voice_bundle_is_default
+ self.loaded = snapshot.loaded
+ self.cur_state = snapshot.cur_state
+
+ def _rebuild_reload_snapshot_runtime(self, snapshot: _ReloadSnapshot) -> None:
+ """Recreate the previous backend runtime from a rollback snapshot."""
+ restored_backend = resolve_backend(
+ snapshot.restorable_backend_spec,
+ log=self.log_callback,
+ **snapshot.backend_kwargs,
+ )
+ if restored_backend.uses_voice_bundles:
+ restored_backend.validate_refs()
+
+ restored_model: Optional[PreTrainedModel] = None
+ restored_model_name = snapshot.model_name
+ if snapshot.loaded and snapshot.current_voice is not None:
+ restored_model, restored_model_name = self._load_backend_voice_runtime(
+ restored_backend,
+ snapshot.current_voice,
+ )
+
+ snapshot.backend = restored_backend
+ snapshot.model = cast(Optional[BackendModel], restored_model)
+ snapshot.model_name = restored_model_name
+
+ def _rebuild_reload_snapshot_vc_runtime(self, snapshot: _ReloadSnapshot) -> None:
+ """Recreate the previous VC backend runtime from a rollback snapshot."""
+ if snapshot.restorable_vc_backend_spec is None:
+ return
+
+ restored_vc_backend = resolve_vc_backend(
+ snapshot.restorable_vc_backend_spec,
+ log=self.log_callback,
+ )
+ if hasattr(restored_vc_backend, "pitch_shift"):
+ setattr(restored_vc_backend, "pitch_shift", self.vc_pitch_shift)
+ if hasattr(restored_vc_backend, "f0_condition"):
+ setattr(restored_vc_backend, "f0_condition", self.vc_f0_condition)
+ if snapshot.loaded:
+ restored_vc_backend.preload_models()
+ snapshot.vc_backend = restored_vc_backend
+
+ def _resolve_voice_state(
+ self,
+ backend: CeluneBackend,
+ preferred_voice: Optional[str] = None,
+ ) -> tuple[
+ tuple[str, ...],
+ Optional[str],
+ Optional[str],
+ Optional[CEVoicePersona],
+ bool,
+ ]:
+ """Resolve voices and character metadata for one backend and active bundle."""
+ if backend.uses_voice_bundles:
+ loader = default_loader()
+ if loader is not None:
+ voices = loader.bundle.voice_order
+ configured_default = loader.bundle.metadata.get("default_voice")
+ default_voice = (
+ configured_default
+ if isinstance(configured_default, str)
+ else backend.default_voice
+ )
+ current_voice = (
+ preferred_voice
+ if preferred_voice in voices
+ else default_voice
+ if default_voice in voices
+ else voices[0]
+ if voices
+ else None
+ )
+ return (
+ voices,
+ current_voice,
+ bundle_character_name(loader.bundle),
+ persona_metadata_from_manifest(loader.bundle.metadata),
+ bundle_matches_default_pack_checksum(loader.bundle.path),
+ )
+
+ voices = tuple(backend.voices)
+ current_voice = (
+ preferred_voice
+ if preferred_voice in voices
+ else backend.default_voice
+ if backend.default_voice in voices
+ else voices[0]
+ if voices
+ else None
+ )
+ return voices, current_voice, None, None, True
+
+ def _load_backend_voice_runtime(
+ self,
+ backend: CeluneBackend,
+ voice: str,
+ ) -> tuple[Optional[PreTrainedModel], str]:
+ """Load the TTS runtime for one backend and voice without disturbing the previous runtime."""
+ model_name = backend.model_id_for_voice(voice)
+ model = cast(PreTrainedModel, backend.load_model(model_name))
+ backend.model = model
+ return model, model_name
+
+ def _hot_reload_backend(
+ self,
+ backend_spec: CoreBackendSpec,
+ preferred_voice: Optional[str] = None,
+ ) -> bool:
+ """Synchronously switch to a new backend family with rollback on failure."""
+ snapshot: Optional[Celune._ReloadSnapshot] = None
+ candidate_kwargs: dict[str, JSONSerializable] = {}
+ candidate_backend: Optional[CeluneBackend] = None
+ candidate_vc_backend: Optional[CeluneVCBackend] = None
+ candidate_model: Optional[PreTrainedModel] = None
+ candidate_voice: Optional[str] = None
+ requested_name = (
+ backend_spec.name
+ if isinstance(backend_spec, (CeluneBackend, CeluneVCBackend))
+ else str(backend_spec)
+ )
+
+ try:
+ snapshot = self._capture_reload_snapshot()
+ preset = resolve_vram_preset(self.config)
+ backend_target, normalized_backend_spec = _core_backend_target(
+ backend_spec,
+ self.log_callback,
+ self.input_mode,
+ )
+ if backend_target == "tts":
+ candidate_kwargs = self._backend_reload_kwargs(
+ cast(TTSBackendSpec, normalized_backend_spec)
+ )
+ self.log(
+ string(
+ "celune.switching_backend",
+ app_name=APP_NAME,
+ backend=requested_name,
+ )
+ )
+ self._ready_announced = False
+ self.status_callback(string("status.reloading_backend"))
+ self.progress_callback(None, None)
+ self.cur_state = "reloading"
+
+ previous_backend = self.backend
+ previous_vc_backend = self.vc_backend
+ previous_voice = snapshot.current_voice
+
+ if backend_target == "tts":
+ candidate_backend = resolve_backend(
+ cast(TTSBackendSpec, normalized_backend_spec),
+ log=self.log_callback,
+ **candidate_kwargs,
+ )
+ self._validate_backend_against_preset(candidate_backend, preset)
+ if candidate_backend.uses_voice_bundles:
+ candidate_backend.validate_refs()
+ candidate_backend.preload_models()
+
+ (
+ candidate_voices,
+ candidate_voice,
+ candidate_character,
+ candidate_persona,
+ candidate_bundle_is_default,
+ ) = self._resolve_voice_state(candidate_backend, preferred_voice)
+ if candidate_voice is None:
+ raise BackendError("no voices found")
+
+ if previous_vc_backend is not None:
+ previous_vc_backend.unload_model()
+ if previous_backend is not candidate_backend:
+ previous_backend.unload_model()
+
+ model, model_name = self._load_backend_voice_runtime(
+ candidate_backend,
+ candidate_voice,
+ )
+ candidate_model = model
+
+ if not self._warmup(
+ fatal_on_failure=False,
+ backend=candidate_backend,
+ model=model,
+ voice=candidate_voice,
+ ):
+ self._raise_warmup_error("warmup failed after backend reload")
+
+ self.backend = candidate_backend
+ self.vc_backend = None
+ self._vc_backend_spec = None
+ self.voice_conversion_backend = ""
+ self.input_mode = "text_to_speech"
+ self.tts_backend = candidate_backend.name
+ self.model = model
+ self.model_name = model_name
+ self.voices = candidate_voices
+ self.current_voice = candidate_voice
+ self.current_character = candidate_character
+ self.current_character_persona = candidate_persona
+ self.voice_bundle_is_default = candidate_bundle_is_default
+ self._backend_spec = (
+ cast(Union[str, type[CeluneBackend]], normalized_backend_spec)
+ if not isinstance(normalized_backend_spec, CeluneBackend)
+ else type(candidate_backend)
+ )
+ self._backend_kwargs = dict(candidate_kwargs)
+ else:
+ candidate_vc_backend = resolve_vc_backend(
+ cast(VCBackendSpec, normalized_backend_spec),
+ log=self.log_callback,
+ )
+ if hasattr(candidate_vc_backend, "pitch_shift"):
+ setattr(candidate_vc_backend, "pitch_shift", self.vc_pitch_shift)
+ if hasattr(candidate_vc_backend, "f0_condition"):
+ setattr(
+ candidate_vc_backend,
+ "f0_condition",
+ self.vc_f0_condition,
+ )
+ candidate_vc_backend.preload_models()
+
+ if previous_backend is not None:
+ previous_backend.unload_model()
+ if (
+ previous_vc_backend is not None
+ and previous_vc_backend is not candidate_vc_backend
+ ):
+ previous_vc_backend.unload_model()
+
+ self.vc_backend = candidate_vc_backend
+ self._vc_backend_spec = (
+ cast(
+ Union[str, type[CeluneVCBackend]],
+ normalized_backend_spec,
+ )
+ if not isinstance(normalized_backend_spec, CeluneVCBackend)
+ else type(candidate_vc_backend)
+ )
+ self.voice_conversion_backend = candidate_vc_backend.name
+ self.input_mode = "voice_conversion"
+ self.model = None
+ self.model_name = ""
+
+ self._release_reload_snapshot(snapshot)
+ if backend_target == "tts" and self.use_normalization:
+ self._unload_normalizer_components()
+ self.load_normalizer()
+ elif backend_target == "vc":
+ self._unload_normalizer_components()
+ self.loaded = True
+ if candidate_backend is not None:
+ if candidate_voice is None:
+ raise BackendError("no voices found")
+ self.voice_changed_callback(candidate_voice)
+ if previous_voice != candidate_voice:
+ self._emit_event(
+ "voice_changed",
+ VoiceChangedEvent(
+ celune=self,
+ old_voice=previous_voice or candidate_voice,
+ new_voice=candidate_voice,
+ ),
+ )
+ self.log(string("celune.switched_backend", backend=requested_name))
+ self.progress_callback(1, 1)
+ self.cur_state = "idle"
+ self.status_callback(string("status.idle"))
+ return True
+ except Exception as e:
+ self.log(
+ string("celune.reload_error", error=format_error(e, self.dev)),
+ "error",
+ )
+ self.status_callback(string("status.restoring_backend"))
+ self.progress_callback(None, None)
+ if (
+ candidate_backend is not None
+ and snapshot is not None
+ and candidate_backend is not snapshot.backend
+ ):
+ candidate_backend.unload_model()
+ elif (
+ candidate_model is not None
+ and snapshot is not None
+ and candidate_model is not snapshot.model
+ ):
+ _release_loaded_object(candidate_model)
+ if (
+ candidate_vc_backend is not None
+ and snapshot is not None
+ and candidate_vc_backend is not snapshot.vc_backend
+ ):
+ candidate_vc_backend.unload_model()
+ if snapshot is not None:
+ if snapshot.input_mode == "voice_conversion":
+ if (
+ snapshot.loaded
+ and snapshot.restorable_vc_backend_spec is not None
+ ):
+ self._rebuild_reload_snapshot_vc_runtime(snapshot)
+ self._restore_reload_snapshot(snapshot)
+ elif snapshot.backend.model is None and snapshot.loaded:
+ self._rebuild_reload_snapshot_runtime(snapshot)
+ self._restore_reload_snapshot(snapshot)
+ else:
+ self._restore_reload_snapshot(snapshot)
+ else:
+ self.cur_state = "idle"
+ self._last_warmup_error = None
+ self.status_callback(string("status.idle"))
+ self.progress_callback(1, 1)
+ self.log(
+ string("celune.backend_restore_failed"),
+ "warning",
+ )
+ return False
+ finally:
+ self._reload_pending = False
+ self._model_ready.set()
+ self.change_input_state_callback(locked=False)
+ self.change_voice_lock_state_callback(locked=len(self.voices) < 2)
+
+ def _hot_reload_cevoice(
+ self,
+ bundle: Optional[Union[str, Path]],
+ preferred_voice: Optional[str] = None,
+ ) -> bool:
+ """Synchronously switch to a new CEVOICE bundle with rollback on failure."""
+ snapshot: Optional[Celune._ReloadSnapshot] = None
+ previous_bundle = active_bundle_path()
+ loaded_model: Optional[PreTrainedModel] = None
+
+ try:
+ snapshot = self._capture_reload_snapshot()
+ previous_loader = default_loader()
+ previous_bundle = (
+ Path(previous_loader.bundle.path)
+ if previous_loader is not None
+ else active_bundle_path()
+ )
+ self.log(string("celune.reloading_character", app_name=APP_NAME))
+ self._ready_announced = False
+ self.status_callback(string("status.reloading_character"))
+ self.progress_callback(None, None)
+ self.cur_state = "reloading"
+
+ select_voice_bundle(bundle)
+ if self.backend.uses_voice_bundles:
+ self.backend.validate_refs()
+
+ (
+ candidate_voices,
+ candidate_voice,
+ candidate_character,
+ candidate_persona,
+ candidate_bundle_is_default,
+ ) = self._resolve_voice_state(self.backend, preferred_voice)
+ if candidate_voice is None:
+ raise BackendError("no voices found in current CEVOICE")
+
+ self.voices = candidate_voices
+ self.current_voice = candidate_voice
+ self.current_character = candidate_character
+ self.current_character_persona = candidate_persona
+ self.voice_bundle_is_default = candidate_bundle_is_default
+
+ if not self._is_voice_conversion_mode():
+ model, model_name = self._load_backend_voice_runtime(
+ self.backend,
+ candidate_voice,
+ )
+ loaded_model = model
+ previous_model = snapshot.model
+ self.model = model
+ self.model_name = model_name
+ if not self._warmup(fatal_on_failure=False):
+ self._raise_warmup_error("warmup failed after CEVOICE reload")
+ if previous_model is not None and previous_model is not model:
+ _release_loaded_object(cast(ReleasableObject, previous_model))
+
+ self.loaded = True
+ self.voice_changed_callback(candidate_voice)
+ self._emit_character_event_transition(
+ snapshot.current_character,
+ str(previous_bundle),
+ self.current_character,
+ str(active_bundle_path()),
+ self.voice_bundle_is_default,
+ )
+ if snapshot.current_voice != candidate_voice:
+ self._emit_event(
+ "voice_changed",
+ VoiceChangedEvent(
+ celune=self,
+ old_voice=snapshot.current_voice or candidate_voice,
+ new_voice=candidate_voice,
+ ),
+ )
+ self.log(
+ string(
+ "celune.switched_character",
+ character=(bundle if bundle is not None else previous_bundle.name),
+ )
+ )
+ self.progress_callback(1, 1)
+ self.cur_state = "idle"
+ self.status_callback(string("status.idle"))
+ return True
+ except Exception as e:
+ self.log(
+ string("celune.reload_error", error=format_error(e, self.dev)),
+ "error",
+ )
+ if (
+ loaded_model is not None
+ and snapshot is not None
+ and loaded_model is not snapshot.model
+ ):
+ _release_loaded_object(loaded_model)
+ select_voice_bundle(previous_bundle)
+ if snapshot is not None:
+ self._restore_reload_snapshot(snapshot)
+ else:
+ self.cur_state = "idle"
+ self.status_callback(string("status.idle"))
+ self.progress_callback(1, 1)
+ self.log(
+ string("celune.character_restore_failed"),
+ "warning",
+ )
+ return False
+ finally:
+ self._reload_pending = False
+ self._model_ready.set()
+ self.change_input_state_callback(locked=False)
+ self.change_voice_lock_state_callback(locked=len(self.voices) < 2)
+
def _raise_warmup_error(self, message: str) -> None:
"""Raise a Celune warmup error while preserving any original cause."""
if self._last_warmup_error is not None:
@@ -552,27 +1501,38 @@ def wake_from_sleep(self) -> bool:
_, _, unload = self._sleep_config()
self.model_ready.clear()
- self.status_callback("Waking up")
+ self.status_callback(string("status.waking_up"))
self.progress_callback(None, None)
self.cur_state = "waking"
try:
with self._model_lock:
- active_voice = self.current_voice or (
- self.voices[0] if self.voices else None
- )
- if active_voice is None:
- raise NotAvailableError("cannot wake without an active voice")
-
- if unload["tts"] or self.model is None:
- if unload["tts"] and self._recreate_tts_backend():
- self.log_dev("[SLEEP] Recreated TTS backend")
- model_id = self.backend.model_id_for_voice(active_voice)
- self.log_dev(f"[SLEEP] Loading model: {model_id}")
- self.model = self.backend.load_model(model_id)
- self.model_name = model_id
- if not self._warmup():
- self._raise_warmup_error("warmup failed after sleep")
+ if self._is_voice_conversion_mode():
+ if self.vc_backend is None:
+ raise NotAvailableError(
+ "cannot wake without a configured voice conversion backend"
+ )
+ if unload["tts"] and self._recreate_vc_backend():
+ self.log_dev("[SLEEP] Recreated VC backend")
+ self.vc_backend.preload_models()
+ else:
+ active_voice = self.current_voice or (
+ self.voices[0] if self.voices else None
+ )
+ if active_voice is None:
+ raise NotAvailableError(
+ "cannot wake without an active voice"
+ )
+
+ if unload["tts"] or self.model is None:
+ if unload["tts"] and self._recreate_tts_backend():
+ self.log_dev("[SLEEP] Recreated TTS backend")
+ model_id = self.backend.model_id_for_voice(active_voice)
+ self.log_dev(f"[SLEEP] Loading model: {model_id}")
+ self.model = self.backend.load_model(model_id)
+ self.model_name = model_id
+ if not self._warmup():
+ self._raise_warmup_error("warmup failed after sleep")
if unload["normalizer"] and self.use_normalization:
self.load_normalizer()
@@ -586,8 +1546,11 @@ def wake_from_sleep(self) -> bool:
persona_quantization(self.config),
)
except Exception as e:
- self.log("Persona not initialized.", "warning")
- self.log("Continuing in speech-only mode.", "warning")
+ self.log(
+ string("celune.persona_not_initialized"),
+ "warning",
+ )
+ self.log(string("celune.speech_only_mode"), "warning")
self.log(format_error(e, self.dev), "warning")
self.vision.close()
self.vision = None
@@ -598,21 +1561,26 @@ def wake_from_sleep(self) -> bool:
self.glow.wake()
self.progress_callback(1, 1)
- self.status_callback("Idle")
+ self.status_callback(string("status.idle"))
self.change_input_state_callback(locked=False)
self.change_voice_lock_state_callback(locked=len(self.voices) < 2)
return True
except Exception as e:
self.cur_state = "error"
self.loaded = False
- self.log(f"[WAKE ERROR] {format_error(e, self.dev)}", "error")
+ self.log(
+ string("celune.wake_error", error=format_error(e, self.dev)),
+ "error",
+ )
self.glow.fatal()
if not self._try_play_signal("error"):
- self.log_dev("Could not play the error signal.", "warning")
+ self.log_dev(string("ui.error_signal_unavailable"), "warning")
self.cur_state = "error"
- self.status_callback(f"{APP_NAME} could not wake", "error")
+ self.status_callback(
+ string("status.could_not_wake", app_name=APP_NAME), "error"
+ )
self.progress_callback(0, 1)
- self.error_callback(f"{APP_NAME} could not wake")
+ self.error_callback(string("status.could_not_wake", app_name=APP_NAME))
return False
finally:
self.model_ready.set()
@@ -634,10 +1602,18 @@ def load_voice_bundle(self, bundle: Optional[Union[str, Path]] = None) -> bool:
Returns:
bool: ``True`` when a CEVOICE bundle was loaded, otherwise ``False``.
"""
+ previous_loader = default_loader()
+ previous_bundle_path = (
+ self._bundle_path_string(previous_loader.bundle)
+ if previous_loader is not None
+ else None
+ )
+ previous_character = self.current_character
select_voice_bundle(bundle)
loader = default_loader()
if loader is None:
self.current_character_persona = None
+ self.current_character = None
self.voice_bundle_is_default = True
voices = tuple(self.backend.voices)
self.voices = voices
@@ -648,9 +1624,19 @@ def load_voice_bundle(self, bundle: Optional[Union[str, Path]] = None) -> bool:
if voices
else None
)
+ self._emit_character_event_transition(
+ previous_character,
+ previous_bundle_path,
+ None,
+ None,
+ True,
+ )
return bool(voices)
- self.voice_bundle_is_default = loader.bundle.path == default_bundle_path()
+ new_bundle_path = self._bundle_path_string(loader.bundle)
+ self.voice_bundle_is_default = bundle_matches_default_pack_checksum(
+ loader.bundle.path
+ )
self.current_character_persona = persona_metadata_from_manifest(
loader.bundle.metadata
)
@@ -670,6 +1656,13 @@ def load_voice_bundle(self, bundle: Optional[Union[str, Path]] = None) -> bool:
if voices
else None
)
+ self._emit_character_event_transition(
+ previous_character,
+ previous_bundle_path,
+ self.current_character,
+ new_bundle_path,
+ self.voice_bundle_is_default,
+ )
return bool(voices)
def load_available_voices(self) -> bool:
@@ -705,13 +1698,13 @@ def set_voice(self, name: str) -> bool:
"""
if name not in self.voices:
# this voice was not found in the current CEVOICE/CECHAR pack
- self.log(f"Unknown voice: {name}", "warning")
+ self.log(string("celune.unknown_voice", voice=name), "warning")
return False
self.change_input_state_callback(locked=True)
if not self._model_ready.is_set():
- self.log("Waiting for models to load...")
+ self.log(string("celune.waiting_for_models"))
self._model_ready.wait(timeout=5)
self._model_ready.clear()
@@ -738,33 +1731,226 @@ def set_voice_and_wait(self, name: str, timeout: float = 30.0) -> bool:
return False
if not self._model_ready.wait(timeout=timeout):
- self.log("Timed out while processing a voice change.", "warning")
+ self.log(string("celune.voice_switch_timeout"), "warning")
return False
return self.loaded and self.current_voice == name
+ def set_backend(
+ self,
+ backend_spec: CoreBackendSpec,
+ ) -> bool:
+ """Request a hot reload into another TTS or VC backend.
+
+ Args:
+ backend_spec: The backend name, type, or instance to activate.
+
+ Returns:
+ bool: ``True`` when the reload worker was started.
+ """
+ if self._reload_pending or self.cur_state == "reloading":
+ self.log(string("celune.reload_already_in_progress"), "warning")
+ return False
+ if isinstance(backend_spec, str):
+ normalized_backend = backend_spec.strip().lower()
+ if (
+ normalized_backend not in BACKENDS
+ and normalized_backend not in VC_BACKENDS
+ ):
+ self.log(
+ string(
+ "celune.unknown_backend",
+ backend=backend_spec,
+ available=", ".join(
+ tuple(BACKENDS.keys()) + tuple(VC_BACKENDS.keys())
+ ),
+ ),
+ "warning",
+ )
+ return False
+
+ self.change_input_state_callback(locked=True)
+ self.change_voice_lock_state_callback(locked=True)
+
+ self._model_ready.clear()
+ self._reload_pending = True
+ self._try_play_signal("working")
+ preferred_voice = self.current_voice
+ threading.Thread(
+ target=self._hot_reload_backend,
+ args=(backend_spec, preferred_voice),
+ daemon=True,
+ ).start()
+ return True
+
+ def set_backend_and_wait(
+ self,
+ backend_spec: CoreBackendSpec,
+ timeout: Optional[float] = None,
+ ) -> bool:
+ """Request a hot backend reload and wait for completion.
+
+ Args:
+ backend_spec: The backend name, type, or instance to activate.
+ timeout: Optional maximum seconds to wait for the reload to finish. Pass ``None`` to wait until the reload
+ completes.
+
+ Returns:
+ bool: ``True`` when the requested backend finished loading.
+ """
+ if not self.set_backend(backend_spec):
+ return False
+
+ if not self._model_ready.wait(timeout=timeout):
+ self.log(string("celune.backend_switch_timeout"), "warning")
+ return False
+
+ target_name = (
+ backend_spec.name
+ if isinstance(backend_spec, (CeluneBackend, CeluneVCBackend))
+ else getattr(backend_spec, "name", str(backend_spec))
+ )
+ return (
+ self.loaded
+ and self._active_runtime_backend_name() == str(target_name).lower()
+ )
+
+ def set_cevoice(self, bundle: Optional[Union[str, Path]]) -> bool:
+ """Request a hot reload into another CEVOICE bundle.
+
+ Args:
+ bundle: The CEVOICE bundle name or path to activate.
+
+ Returns:
+ bool: ``True`` when the reload worker was started.
+ """
+ if self._reload_pending or self.cur_state == "reloading":
+ self.log(string("celune.reload_already_in_progress"), "warning")
+ return False
+
+ if bundle is not None:
+ resolved_bundle = resolve_bundle_path(bundle)
+ if not resolved_bundle.exists():
+ self.log(
+ string("celune.voice_pack_not_found", bundle=bundle), "warning"
+ )
+ return False
+
+ self.change_input_state_callback(locked=True)
+ self.change_voice_lock_state_callback(locked=True)
+
+ self._model_ready.clear()
+ self._reload_pending = True
+ threading.Thread(
+ target=self._hot_reload_cevoice,
+ args=(bundle, None),
+ daemon=True,
+ ).start()
+ return True
+
+ def set_cevoice_and_wait(
+ self,
+ bundle: Optional[Union[str, Path]],
+ timeout: Optional[float] = None,
+ ) -> bool:
+ """Request a hot CEVOICE reload and wait for completion.
+
+ Args:
+ bundle: The CEVOICE bundle name or path to activate.
+ timeout: Optional maximum seconds to wait for the reload to finish. Pass ``None`` to wait until the reload
+ completes.
+
+ Returns:
+ bool: ``True`` when the requested CEVOICE pack finished loading.
+ """
+ if not self.set_cevoice(bundle):
+ return False
+
+ if not self._model_ready.wait(timeout=timeout):
+ self.log(string("celune.character_switch_timeout"), "warning")
+ return False
+ return self.loaded and active_bundle_path() == resolve_bundle_path(bundle)
+
+ @contextlib.contextmanager
+ def with_backend(
+ self,
+ backend_spec: CoreBackendSpec,
+ timeout: float = 30.0,
+ ):
+ """Temporarily switch Celune to another backend within a context block.
+
+ Args:
+ backend_spec: The backend name, type, or instance to activate temporarily.
+ timeout: Maximum seconds to wait while switching or restoring the backend.
+
+ Raises:
+ BackendError: Celune could not switch to or restore the requested backend.
+ """
+ restore_backend = self._restorable_active_backend_spec()
+ restore_voice = self.current_voice
+ if not self.wait_until_idle(timeout=timeout):
+ raise BackendError("timed out switching backend")
+ if not self._hot_reload_backend(backend_spec, self.current_voice):
+ raise BackendError("failed to switch backend")
+
+ try:
+ yield self
+ finally:
+ self.wait_until_idle(timeout=timeout)
+ if not self._hot_reload_backend(restore_backend, restore_voice):
+ raise BackendError("failed to restore old backend")
+
+ @contextlib.contextmanager
+ def with_cevoice(
+ self,
+ bundle: Optional[Union[str, Path]],
+ timeout: float = 30.0,
+ ):
+ """Temporarily switch Celune to another CEVOICE bundle within a context block.
+
+ Args:
+ bundle: The CEVOICE bundle name or path to activate temporarily.
+ timeout: Maximum seconds to wait while switching or restoring the CEVOICE pack.
+
+ Raises:
+ BackendError: Celune could not switch to or restore the requested CEVOICE pack.
+ """
+ previous_loader = default_loader()
+ restore_bundle = (
+ Path(previous_loader.bundle.path)
+ if previous_loader is not None
+ else active_bundle_path()
+ )
+ restore_voice = self.current_voice
+ if not self.wait_until_idle(timeout=timeout):
+ raise BackendError("timed out switching character")
+ if not self._hot_reload_cevoice(bundle, None):
+ raise BackendError("failed to switch character")
+
+ try:
+ yield self
+ finally:
+ self.wait_until_idle(timeout=timeout)
+ if not self._hot_reload_cevoice(restore_bundle, restore_voice):
+ raise BackendError("failed to restore character")
+
def _wait_until_idle(self, timeout: float = 30.0) -> bool:
"""Wait until the model and playback pipeline are ready."""
# don't wait a timeout while Celune is downloading a model
ok = self._model_ready.wait(timeout=timeout)
if not ok:
- self.log("Timed out while waiting to become ready.", "warning")
- self.log(
- "A possible reason for this may be a model download or high GPU activity.",
- "warning",
- )
- self.log(
- "This is not a fatal error, the utterance may be retried.", "warning"
- )
+ self.log(string("celune.ready_wait_timeout"), "warning")
+ self.log(string("celune.ready_wait_reason"), "warning")
+ self.log(string("celune.ready_wait_not_fatal"), "warning")
return False
if not self.loaded:
- self.log("Model was unloaded while waiting to become ready.", "warning")
+ self.log(string("celune.model_unloaded_while_waiting"), "warning")
return False
ok = self._playback_done.wait(timeout=timeout)
if not ok:
self.log(
- "Timed out while waiting for playback pipeline to become idle.",
+ string("celune.playback_idle_timeout"),
"warning",
)
return False
@@ -785,12 +1971,14 @@ def setup_extensions(self) -> None:
set_voice=self.set_voice,
get_state=lambda: self.cur_state,
wait_until_ready=self._wait_until_idle,
+ backend_override=self.with_backend,
+ cevoice_override=self.with_cevoice,
name=APP_NAME,
version=__version__,
dev=self.dev,
log_dev=self.log_dev,
)
- self.extension_manager = CeluneExtensionManager(ctx)
+ self.extension_manager = CeluneExtensionManager(ctx, self._event_dispatcher)
self.extension_manager.autoload(str(project_root() / "extensions"))
self.log_dev(
@@ -872,54 +2060,79 @@ def change_voice(self, voice: str) -> None:
WarmupError: The newly loaded voice fails warmup.
"""
- self.log(f"{APP_NAME} is reloading, please stand by...")
+ self.log(string("celune.reloading", app_name=APP_NAME))
self._ready_announced = False
- self.status_callback("Reloading")
+ self.status_callback(string("status.reloading"))
self.progress_callback(None, None)
self.cur_state = "reloading"
+ active_voice = self.current_voice or voice
try:
with self._model_lock:
- new_model_name = self.backend.model_id_for_voice(voice)
-
- # VoxCPM2 uses the same model for all voices, so we don't have to reload every time
- if new_model_name != self.model_name:
- if not self._try_play_signal("working"):
- self.log_dev("Could not play the working signal.", "warning")
- self.log_dev(f"[RELOAD] Unloading model: {self.model_name}")
- self.unload_runtime_state(include_normalizer=False)
- self.log_dev(f"[RELOAD] Loading model: {new_model_name}")
- self.model = self.backend.load_model(new_model_name)
-
- self.log("Rewarming up...")
- if not self._warmup():
- self._raise_warmup_error("warmup failed after reload")
-
- if not self._try_play_signal("readiness"):
- self.log_dev("Could not play the readiness signal.", "warning")
-
- self.log_dev(
- "[RELOAD] The target model is the same as the model currently in use."
- )
+ if self._is_voice_conversion_mode():
+ self.current_voice = voice
+ self.loaded = True
+ else:
+ new_model_name = self.backend.model_id_for_voice(voice)
+
+ # VoxCPM2 uses the same model for all voices, so we don't have to reload every time
+ if new_model_name != self.model_name:
+ if not self._try_play_signal("working"):
+ self.log_dev(
+ "Could not play the working signal.", "warning"
+ )
+ self.log_dev(f"[RELOAD] Unloading model: {self.model_name}")
+ self.unload_runtime_state(include_normalizer=False)
+ self.log_dev(f"[RELOAD] Loading model: {new_model_name}")
+ self.model = self.backend.load_model(new_model_name)
+ self.model_name = new_model_name
+
+ self.log(string("celune.rewarming_up"))
+ if not self._warmup():
+ self._raise_warmup_error("warmup failed after reload")
- self.current_voice = voice
- self.loaded = True
+ if not self._try_play_signal("readiness"):
+ self.log_dev(
+ "Could not play the readiness signal.", "warning"
+ )
+
+ self.log_dev(
+ "[RELOAD] The target model is the same as the model currently in use."
+ )
+
+ self.current_voice = voice
+ self.loaded = True
self.voice_changed_callback(voice)
- self.log(f"Voice {voice} loaded.")
+ if active_voice != voice:
+ self._emit_event(
+ "voice_changed",
+ VoiceChangedEvent(
+ celune=self,
+ old_voice=active_voice,
+ new_voice=voice,
+ ),
+ )
+ self.log(string("celune.voice_loaded", voice=voice))
self.progress_callback(1, 1)
self.cur_state = "idle"
- self.status_callback("Idle")
+ self.status_callback(string("status.idle"))
except Exception as e:
self.cur_state = "error"
self.loaded = False
- self.log(f"[RELOAD ERROR] {format_error(e, self.dev)}", "error")
+ self.log(
+ string("celune.reload_error", error=format_error(e, self.dev)),
+ "error",
+ )
self.glow.fatal()
if not self._try_play_signal("error"):
- self.log_dev("Could not play the error signal.", "warning")
- self.status_callback(f"{APP_NAME} could not reload", "error")
+ self.log_dev(string("ui.error_signal_unavailable"), "warning")
+ self.status_callback(
+ string("status.could_not_reload", app_name=APP_NAME),
+ "error",
+ )
self.progress_callback(0, 1)
- self.error_callback(f"{APP_NAME} could not reload")
+ self.error_callback(string("status.could_not_reload", app_name=APP_NAME))
finally:
self._model_ready.set()
self.change_input_state_callback(locked=False)
@@ -939,19 +2152,21 @@ def load(self) -> bool:
Returns:
bool: ``True`` when initialization completed successfully, otherwise ``False``.
"""
- disable_progress_bar()
- disable_progress_bars()
- hf_logging.set_verbosity_error()
+ log_runtime_banner(self.log, self.vc_backend or self.backend)
+
+ if self.backend.is_fake and "pytest" not in sys.modules:
+ self.log(string("celune.test_mode_active", app_name=APP_NAME))
+ return True
- log_runtime_banner(self.log, self.backend.name)
+ self._cleanup_residual_temp_data(app_data_dir() / "temp")
if not self.load_available_voices():
self.cur_state = "error"
- self.log("No voices were loaded.", "error")
+ self.log(string("celune.no_voices_loaded"), "error")
self.glow.fatal()
if not self._try_play_signal("error"):
- self.log_dev("Could not play the error signal.", "warning")
+ self.log_dev(string("ui.error_signal_unavailable"), "warning")
self.progress_callback(0, 1)
- self.error_callback("No voices loaded")
+ self.error_callback(string("celune.no_voices_loaded_short"))
return False
if self.backend.uses_voice_bundles:
@@ -959,13 +2174,12 @@ def load(self) -> bool:
character = self.current_character or announced_character
self.current_character = character
- # the default pack's SHA256 hash is:
- # 22ff70762e7f6f3e734cc62c81c286f7482de6155b2394e7a6ddec1a892f63e0
- # please check it later, or else non-default packs named Celune will show the "default" tag
- if character == "Celune":
- self.log(f"Current character: {character} (default)")
+ if self.voice_bundle_is_default:
+ self.log(
+ string("celune.current_character_default", character=character)
+ )
else:
- self.log(f"Current character: {character}")
+ self.log(string("celune.current_character", character=character))
self.setup_extensions()
@@ -974,53 +2188,79 @@ def load(self) -> bool:
self.log(vram_message, "warning")
self.log(
- f"Current VRAM preset: {str(self.config.get('vram', 'unknown')).title()}"
+ string(
+ "celune.current_vram_preset",
+ preset=str(self.config.get("vram", "unknown")).title(),
+ )
)
self.progress_callback(None, None)
- self.backend.preload_models()
+ if self._is_voice_conversion_mode():
+ if self.vc_backend is None:
+ self.cur_state = "error"
+ self.log(string("celune.no_vc_backend"), "error")
+ self.glow.fatal()
+ if not self._try_play_signal("error"):
+ self.log_dev(string("ui.error_signal_unavailable"), "warning")
+ self.progress_callback(0, 1)
+ self.error_callback(string("celune.no_valid_vc_backend"))
+ return False
- self.log("All voices are available.")
- try:
- self.model = self.backend.load_default_model()
- active_voice = self.current_voice or self.voices[0]
- self.model_name = self.backend.model_id_for_voice(active_voice)
- except Exception as e:
- self.cur_state = "error"
- self.log(f"{APP_NAME} could not load the default model.", "error")
- self.log(format_error(e, self.dev), "error")
- self.glow.fatal()
- if not self._try_play_signal("error"):
- self.log_dev("Could not play the error signal.", "warning")
- self.progress_callback(0, 1)
- self.error_callback("Default model failed to load")
- return False
+ self.vc_backend.preload_models()
+ self.model = None
+ self.model_name = ""
+ self.log(string("celune.ready_for_vc"))
+ else:
+ self.backend.preload_models()
+
+ self.log(string("celune.all_voices_available"))
+ try:
+ self.model = self.backend.load_default_model()
+ active_voice = self.current_voice or self.voices[0]
+ self.model_name = self.backend.model_id_for_voice(active_voice)
+ except Exception as e:
+ self.cur_state = "error"
+ self.log(
+ string("celune.default_model_load_failed", app_name=APP_NAME),
+ "error",
+ )
+ self.log(format_error(e, self.dev), "error")
+ self.glow.fatal()
+ if not self._try_play_signal("error"):
+ self.log_dev(string("ui.error_signal_unavailable"), "warning")
+ self.progress_callback(0, 1)
+ self.error_callback(string("celune.default_model_failed_short"))
+ return False
if self.vision is not None:
- self.log("Initializing Persona...")
+ self.log(string("celune.initializing_persona"))
try:
self.vision.load(
persona_model_id(self.config),
persona_quantization(self.config),
)
except Exception as e:
- self.log("Persona not initialized.", "warning")
- self.log("Continuing in speech-only mode.", "warning")
+ self.log(string("celune.persona_not_initialized"), "warning")
+ self.log(string("celune.speech_only_mode"), "warning")
self.log(format_error(e, self.dev), "warning")
self.vision.close()
self.vision = None
else:
- self.log("Persona initialized.")
+ self.log(string("celune.persona_initialized"))
- generation_thread = threading.Thread(
- target=self._generation_worker, daemon=True
- )
playback_thread = threading.Thread(target=self._playback_worker, daemon=True)
+ generation_thread = None
+ if not self._is_voice_conversion_mode():
+ generation_thread = threading.Thread(
+ target=self._generation_worker, daemon=True
+ )
+
self._generation_thread = generation_thread
self._playback_thread = playback_thread
- generation_thread.start()
+ if generation_thread is not None:
+ generation_thread.start()
playback_thread.start()
if not validate_runtime(
@@ -1030,38 +2270,39 @@ def load(self) -> bool:
glow_connect_failed=self.glow.connect_failed,
format_error=format_error,
dev=self.dev,
- backend_name=self.backend.name,
+ backend_name=self._active_runtime_backend_name(),
):
self.cur_state = "error"
self.glow.fatal()
if not self._try_play_signal("error"):
- self.log_dev("Could not play the error signal.", "warning")
+ self.log_dev(string("ui.error_signal_unavailable"), "warning")
return False
- if self._warmup():
+ warmup_ok = True
+ if not self._is_voice_conversion_mode():
+ warmup_ok = self._warmup()
+
+ if warmup_ok:
self.loaded = True
self._model_ready.set()
self._release_pipeline()
self.glow.enter() # Celune has entered your PC
else:
self.cur_state = "error"
- self.log("[WARMUP] Warmup failed.", "error")
+ self.log(string("celune.warmup_failed"), "error")
self.glow.fatal()
if not self._try_play_signal("error"):
- self.log("Could not play the error signal.", "warning")
+ self.log(string("ui.error_signal_unavailable"), "warning")
return False
if self.use_normalization:
self.load_normalizer()
- if self.extension_manager is not None:
- self.extension_manager.autostart_all()
-
self._start_configured_api()
if persona_enabled(self.config) and not persona_is_available():
self.log(
- f"Personas are unavailable. {APP_NAME} is operating in speech-only mode.",
+ string("celune.personas_unavailable", app_name=APP_NAME),
"warning",
)
@@ -1069,6 +2310,8 @@ def load(self) -> bool:
if not self._try_play_signal("readiness"):
self.log_dev("Could not play the readiness signal.", "warning")
+ self._emit_event("ready", ReadyEvent(celune=self))
+
return True
def _api_settings(self) -> tuple[bool, str, int, Optional[str], int]:
@@ -1090,7 +2333,7 @@ def _api_settings(self) -> tuple[bool, str, int, Optional[str], int]:
token = str(token_value).strip() if token_value is not None else None
if not token:
self.log(
- f"No API token set. {APP_NAME} API will bind only to the local network.",
+ string("celune.no_api_token", app_name=APP_NAME),
"warning",
)
token = None
@@ -1100,14 +2343,14 @@ def _api_settings(self) -> tuple[bool, str, int, Optional[str], int]:
except (TypeError, ValueError):
invalid_port = api_config.get("port", 2060)
self.log(
- f"{APP_NAME} API port ({invalid_port}) is invalid, will use 2060 instead.",
+ string("celune.api_port_invalid", app_name=APP_NAME, port=invalid_port),
"warning",
)
port = 2060
if not 1 <= port <= 65535:
self.log(
- f"{APP_NAME} API port ({port}) is out of range, will use 2060 instead.",
+ string("celune.api_port_out_of_range", app_name=APP_NAME, port=port),
"warning",
)
port = 2060
@@ -1120,7 +2363,11 @@ def _api_settings(self) -> tuple[bool, str, int, Optional[str], int]:
except (TypeError, ValueError):
invalid_ratelimit = api_config.get("rate_limit_per_minute", 60)
self.log(
- f"{APP_NAME} API rate limit ({invalid_ratelimit}) is invalid, using 60/min.",
+ string(
+ "celune.api_rate_limit_invalid",
+ app_name=APP_NAME,
+ rate=invalid_ratelimit,
+ ),
"warning",
)
requests_per_minute = 60
@@ -1136,22 +2383,28 @@ def _start_configured_api(self) -> None:
return
if not is_port_usable(port):
- self.log(f"Port {port} is unavailable.", "warning")
- self.log(f"{APP_NAME} API will not be available.", "warning")
+ self.log(string("celune.port_unavailable", port=port), "warning")
+ self.log(string("celune.api_unavailable", app_name=APP_NAME), "warning")
return
try:
from .api import start_api
except ModuleNotFoundError as package:
self.log(
- f"A required package ({package.name}) isn't installed.",
+ string(
+ "celune.required_package_missing",
+ package=package.name,
+ ),
"warning",
)
- self.log(f"{APP_NAME} API will not be available.", "warning")
+ self.log(string("celune.api_unavailable", app_name=APP_NAME), "warning")
return
except Exception as e:
- self.log(f"Package import failed: {format_error(e, self.dev)}", "warning")
- self.log(f"{APP_NAME} API will not be available.", "warning")
+ self.log(
+ string("celune.package_import_failed", error=format_error(e, self.dev)),
+ "warning",
+ )
+ self.log(string("celune.api_unavailable", app_name=APP_NAME), "warning")
return
try:
@@ -1164,9 +2417,10 @@ def _start_configured_api(self) -> None:
)
except Exception as e:
self.log(
- f"An internal error occurred: {format_error(e, self.dev)}", "warning"
+ string("celune.internal_error", error=format_error(e, self.dev)),
+ "warning",
)
- self.log(f"{APP_NAME} API will not be available.", "warning")
+ self.log(string("celune.api_unavailable", app_name=APP_NAME), "warning")
return
def load_normalizer(self) -> None:
@@ -1203,12 +2457,18 @@ def _worker():
self.tokenizer = loaded_tokenizer
self.llm = loaded_llm
- self.log("Normalizer loaded.")
+ self.log(string("celune.normalizer_loaded"))
self.progress_callback(1, 1)
except Exception as e:
- self.log(f"[NORMALIZER ERROR] {format_error(e, self.dev)}", "error")
- self.log("Normalizer failed to load.", "warning")
- self.log("Normalization will not be available.", "warning")
+ self.log(
+ string(
+ "celune.normalizer_error",
+ error=format_error(e, self.dev),
+ ),
+ "error",
+ )
+ self.log(string("celune.normalizer_failed"), "warning")
+ self.log(string("celune.normalization_unavailable"), "warning")
self.progress_callback(0, 1)
if self.vision is not None:
@@ -1219,17 +2479,29 @@ def _worker():
thread.start()
self.progress_callback(None, None)
self.log(
- f"Loading normalizer {NORMALIZER_MODEL_ID} on "
- f"{normalizer_device(self.config)}..."
+ string(
+ "celune.loading_normalizer",
+ model_id=NORMALIZER_MODEL_ID,
+ device=normalizer_device(self.config),
+ )
)
- def _warmup(self) -> bool:
+ def _warmup(
+ self,
+ fatal_on_failure: bool = True,
+ backend: Optional[CeluneBackend] = None,
+ model: Optional[PreTrainedModel] = None,
+ voice: Optional[str] = None,
+ ) -> bool:
"""Warm up Celune's speech capabilities."""
- self.log("[WARMUP] Warming up...")
- self.status_callback("Warming up")
+ self.log(string("celune.warmup_start"))
+ self.status_callback(string("status.warming_up"))
self.progress_callback(None, None)
warmup_text = "A"
self._last_warmup_error = None
+ active_backend = backend if backend is not None else self.backend
+ active_model = model if model is not None else self.model
+ active_voice = voice if voice is not None else self.current_voice
forced_error = os.getenv("CELUNE_FORCE_ERROR") in {
"1",
@@ -1246,16 +2518,16 @@ def _warmup(self) -> bool:
warmup_start = time.perf_counter()
with self._model_lock:
- if self.model is None:
+ if active_model is None:
raise WarmupError("cannot warm up a null model")
- for _, _, _ in self.backend.generate_stream(
- self.model,
+ for _, _, _ in active_backend.generate_stream(
+ active_model,
text=warmup_text,
language=self.language,
chunk_size=self.chunk_size,
instruct=self.effective_voice_prompt(),
- voice=self.current_voice,
+ voice=active_voice,
):
pass
@@ -1266,15 +2538,20 @@ def _warmup(self) -> bool:
self.progress_callback(1, 1)
return True
except Exception as e:
- self.cur_state = "error"
self._last_warmup_error = e
- self.log(f"[WARMUP ERROR] {format_error(e, self.dev)}", "error")
- self.cur_state = "error"
- self.glow.fatal()
- if not self._try_play_signal("error"):
- self.log_dev("Could not play the error signal.", "warning")
+ self.log(
+ string("celune.warmup_error", error=format_error(e, self.dev)),
+ "error",
+ )
self.progress_callback(0, 1)
- self.error_callback(f"{APP_NAME} could not warm up")
+ if fatal_on_failure:
+ self.cur_state = "error"
+ self.glow.fatal()
+ if not self._try_play_signal("error"):
+ self.log_dev(string("ui.error_signal_unavailable"), "warning")
+ self.error_callback(
+ string("celune.warmup_failed_app", app_name=APP_NAME)
+ )
return False
# as of CeluneNorm 2.0, normalization ACTUALLY works with long inputs
@@ -1333,9 +2610,9 @@ def _run_inference() -> Optional[str]:
token_ids = cast(torch.Tensor, tokens["input_ids"])
len_tokens = token_ids.shape[1]
- self.log(f"Tokens to normalize: {len_tokens}")
+ self.log(string("celune.tokens_to_normalize", count=len_tokens))
if len_tokens > 512:
- self.log("Input is too long to normalize.", "warning")
+ self.log(string("celune.input_too_long_to_normalize"), "warning")
return None
with torch.inference_mode():
@@ -1354,7 +2631,7 @@ def _run_inference() -> Optional[str]:
# CeluneNorm shouldn't do this, but if it does happen, stop Celune from saying nothing
if new_ids.numel() == 0:
- self.log("Normalizer returned no tokens.", "warning")
+ self.log(string("celune.normalizer_returned_no_tokens"), "warning")
return None
out = tokenizer.decode(new_ids, skip_special_tokens=True)
@@ -1369,18 +2646,26 @@ def _run_inference() -> Optional[str]:
# are we absolutely sure CeluneNorm did produce something before Celune gets to say it?
if not out:
- self.log("Normalizer did not produce normal output.", "warning")
+ self.log(string("celune.normalizer_bad_output"), "warning")
return None
inf_total = time.perf_counter() - inf_start
- self.log(f"Normalized text: {out}")
- self.log(f"Normalization took {format_number(inf_total, 2)} seconds.")
+ self.log(string("celune.normalized_text", text=out))
+ self.log(
+ string(
+ "celune.normalization_took",
+ seconds=format_number(inf_total, 2),
+ )
+ )
return out
except Exception as e:
self.log(
- f"[NORMALIZATION ERROR] {format_error(e, self.dev)}",
+ string(
+ "celune.normalization_error",
+ error=format_error(e, self.dev),
+ ),
"error",
)
return None
@@ -1407,22 +2692,30 @@ def think(
Returns:
bool: ``True`` if Celune processed this smart request, otherwise ``False``.
"""
+ if self.input_mode != "text_to_speech":
+ self.log(string("celune.text_input_unavailable_vc"), "warning")
+ self.error_callback(string("celune.not_possible"))
+ return False
+
if self.is_in_tutorial:
- self.log("Speech input is disabled during the tutorial.", "warning")
+ self.log(string("celune.speech_input_disabled_tutorial"), "warning")
return False
if self.sleeping:
- self.log(f"Cannot think while {APP_NAME} is sleeping.", "warning")
- self.error_callback(f"{APP_NAME} is currently sleeping")
+ self.log(
+ string("celune.cannot_think_sleeping", app_name=APP_NAME),
+ "warning",
+ )
+ self.error_callback(string("celune.app_sleeping", app_name=APP_NAME))
return False
with self.say_lock:
if self.locked or self.cur_state in {"generating", "speaking"}:
- self.log(f"Tried to think while {APP_NAME} was busy.", "warning")
- self.error_callback(f"{APP_NAME} is currently busy")
+ self.log(string("celune.busy_thinking", app_name=APP_NAME), "warning")
+ self.error_callback(string("celune.app_busy", app_name=APP_NAME))
return False
- self.status_callback("Thinking")
+ self.status_callback(string("status.thinking"))
self.cur_state = "thinking"
self.progress_callback(None, None)
self._ready_announced = False
@@ -1445,7 +2738,7 @@ def _think_worker(self, text: str) -> None:
return
if not think_pipeline(self, text):
- self.log("Will say the input instead.", "warning")
+ self.log(string("celune.say_instead"), "warning")
self.say(text)
def say(
@@ -1464,6 +2757,12 @@ def say(
Returns:
bool: ``True`` when the text was queued successfully, otherwise ``False``.
"""
+ if self.input_mode != "text_to_speech":
+ self.log(string("celune.text_input_unavailable_vc"), "warning")
+ self.error_callback(string("celune.not_possible"))
+ self.progress_callback(0, 1)
+ return False
+
return say_pipeline(self, text, save=save, display_text=display_text)
def say_stream(self, text: str, save: bool = True) -> Optional[SpeechStreamQueue]:
@@ -1482,6 +2781,84 @@ def say_stream(self, text: str, save: bool = True) -> Optional[SpeechStreamQueue
return None
return stream_queue
+ def submit_audio(
+ self,
+ audio: npt.NDArray[np.float32],
+ sample_rate: int,
+ label: str = "audio input",
+ pitch_shift: Optional[int] = None,
+ f0_condition: Optional[bool] = None,
+ log_playback: bool = True,
+ reset_ready_announcement: bool = True,
+ ) -> bool:
+ """Accept audio input for future non-TTS engine modes.
+
+ Args:
+ audio: Decoded mono or stereo input audio.
+ sample_rate: Sample rate for the submitted audio.
+ label: Human-readable label for the input source.
+ pitch_shift: Optional semitone adjustment to apply during VC.
+ f0_condition: Optional override enabling Seed-VC singing mode.
+ log_playback: Whether playback timing and length info should be logged.
+ reset_ready_announcement: Whether this audio input should allow a later ready message once playback
+ completes.
+
+ Returns:
+ bool: ``True`` when the current mode accepted the audio input.
+ """
+ return handle_audio_input(
+ self,
+ AudioInputRequest(
+ audio=np.asarray(audio, dtype=np.float32),
+ sample_rate=sample_rate,
+ label=label,
+ pitch_shift=pitch_shift,
+ f0_condition=f0_condition,
+ log_playback=log_playback,
+ reset_ready_announcement=reset_ready_announcement,
+ ),
+ )
+
+ def convert_audio(
+ self,
+ audio: npt.NDArray[np.float32],
+ sample_rate: int,
+ label: str = "audio input",
+ pitch_shift: Optional[int] = None,
+ f0_condition: Optional[bool] = None,
+ ) -> Optional[AudioOutput]:
+ """Convert submitted audio and return the generated VC output.
+
+ Args:
+ audio: Decoded mono or stereo input audio.
+ sample_rate: Sample rate for the submitted audio.
+ label: Human-readable label for the input source.
+ pitch_shift: Optional semitone adjustment overriding the VC default.
+ f0_condition: Optional override enabling Seed-VC singing mode.
+
+ Returns:
+ Optional[AudioOutput]: The converted audio output, or ``None`` when voice conversion is unavailable.
+ """
+ if not self._is_voice_conversion_mode():
+ self.log(
+ string("celune.audio_conversion_unavailable"),
+ "warning",
+ )
+ self.error_callback(string("celune.not_possible"))
+ self.progress_callback(0, 1)
+ return None
+
+ return convert_audio_input(
+ self,
+ AudioInputRequest(
+ audio=np.asarray(audio, dtype=np.float32),
+ sample_rate=sample_rate,
+ label=label,
+ pitch_shift=pitch_shift,
+ f0_condition=f0_condition,
+ ),
+ )
+
def play(self, sound_path: str, keep: bool = False, volume: float = 1.0) -> bool:
"""Play a sound via Celune's pipeline.
@@ -1517,6 +2894,7 @@ def play_audio(
def close(self) -> None:
"""Shut off Celune and release loaded runtime state."""
+ self._emit_event("shutdown", ShutdownEvent(celune=self))
try:
close_pipeline(self)
self._unload_persona_state()
diff --git a/celune/cevoice.py b/celune/cevoice.py
index 5fb4709..f7826e2 100644
--- a/celune/cevoice.py
+++ b/celune/cevoice.py
@@ -9,6 +9,8 @@
import struct
import hashlib
import tempfile
+import threading
+import contextlib
from pathlib import Path
from dataclasses import dataclass, field
from typing import BinaryIO, Callable, Final, Mapping, Optional, Union, cast
@@ -30,6 +32,9 @@
HEADER = struct.Struct("<8sHI")
ALLOWED_ASSET_KINDS = {"wav", "pt"}
+DEFAULT_CEVOICE_PACK_SHA256: Final[str] = (
+ "22ff70762e7f6f3e734cc62c81c286f7482de6155b2394e7a6ddec1a892f63e0"
+)
@dataclass(frozen=True)
@@ -211,6 +216,7 @@ def __init__(self, bundle: CEVoice) -> None:
dir=str(temp_data_dir(create=True)),
)
)
+ register_protected_temp_path(self._directory)
self._paths: dict[tuple[str, str], Path] = {}
atexit.register(self.close)
@@ -229,20 +235,24 @@ def materialize(self, voice: str, kind: str, suffix: Optional[str] = None) -> Pa
CEVoiceError: The CEVOICE/CECHAR package contains path delimiters.
"""
key = (voice, kind)
- if key not in self._paths:
+ path = self._paths.get(key)
+ if path is None or not path.is_file():
if "/" in voice or "\\" in voice or voice in {"", ".", ".."}:
raise CEVoiceError(f"invalid voice name '{voice}'")
if "/" in kind or "\\" in kind or kind in {"", ".", ".."}:
raise CEVoiceError(f"invalid asset kind '{kind}'")
extension = suffix or f".{kind}"
safe_voice = Path(voice).name
+ register_protected_temp_path(self._directory)
+ self._directory.mkdir(parents=True, exist_ok=True)
path = self._directory / f"{safe_voice}{extension}"
path.write_bytes(self.bundle.read_asset(voice, kind))
self._paths[key] = path
- return self._paths[key]
+ return path
def close(self) -> None:
"""Remove extracted temporary files."""
+ unregister_protected_temp_path(self._directory)
shutil.rmtree(self._directory, ignore_errors=True)
@@ -607,6 +617,56 @@ def _manifest_text(value: ManifestValue) -> str:
_SELECTED_BUNDLE: Optional[Path] = None
_SELECTED_BUNDLE_IS_NAMED = False
_DEFAULT_LOADER_FELL_BACK_FROM: Optional[Path] = None
+_PROTECTED_TEMP_PATHS: set[Path] = set()
+_PROTECTED_TEMP_PATHS_LOCK = threading.RLock()
+
+
+def register_protected_temp_path(path: Union[str, Path]) -> Path:
+ """Register one live temp path that Celune cleanup must not delete.
+
+ Args:
+ path: The live temp path to protect.
+
+ Returns:
+ Path: The normalized protected path.
+ """
+ resolved = Path(path).resolve()
+ with _PROTECTED_TEMP_PATHS_LOCK:
+ _PROTECTED_TEMP_PATHS.add(resolved)
+ return resolved
+
+
+def unregister_protected_temp_path(path: Union[str, Path]) -> None:
+ """Remove one previously protected temp path from cleanup protection.
+
+ Args:
+ path: The temp path to unprotect.
+ """
+ resolved = Path(path).resolve()
+ with _PROTECTED_TEMP_PATHS_LOCK:
+ _PROTECTED_TEMP_PATHS.discard(resolved)
+
+
+def is_protected_temp_path(path: Union[str, Path]) -> bool:
+ """Return whether one temp path is protected from Celune cleanup.
+
+ Args:
+ path: The temp path to check.
+
+ Returns:
+ bool: ``True`` when the path is registered directly or nested under one that is.
+ """
+ resolved = Path(path).resolve()
+ with _PROTECTED_TEMP_PATHS_LOCK:
+ protected_paths = tuple(_PROTECTED_TEMP_PATHS)
+
+ for protected in protected_paths:
+ if resolved == protected:
+ return True
+ with contextlib.suppress(ValueError):
+ resolved.relative_to(protected)
+ return True
+ return False
def default_bundle_path() -> Path:
@@ -618,6 +678,37 @@ def default_bundle_path() -> Path:
return project_root() / "voices" / "default.cevoice"
+def bundle_sha256(path: Union[str, Path]) -> str:
+ """Return the SHA-256 checksum of one CEVOICE/CECHAR bundle file.
+
+ Args:
+ path: The bundle file to hash.
+
+ Returns:
+ str: The lowercase hexadecimal SHA-256 checksum for the bundle.
+ """
+ digest = hashlib.sha256()
+ with Path(path).open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def bundle_matches_default_pack_checksum(path: Union[str, Path]) -> bool:
+ """Return whether one bundle matches Celune's canonical default pack bytes.
+
+ Args:
+ path: The bundle file to compare against Celune's bundled default pack checksum.
+
+ Returns:
+ bool: ``True`` when the file checksum matches the canonical default CEVOICE pack.
+ """
+ try:
+ return bundle_sha256(path) == DEFAULT_CEVOICE_PACK_SHA256
+ except OSError:
+ return False
+
+
def bundled_voices_dir() -> Path:
"""Return the repository-level directory that stores bundled voice packs.
diff --git a/celune/chroma.py b/celune/chroma.py
index 9c1310d..acae744 100644
--- a/celune/chroma.py
+++ b/celune/chroma.py
@@ -19,7 +19,7 @@
from .dsp import split
from .colors import RGB, ERROR
from .constants import BASE_SR
-from .utils import to_rgb, lunar_info, range_interpolated, is_celune_day
+from .utils import to_rgb, lunar_info, range_interpolated, is_celune_day, discard
if TYPE_CHECKING:
from .celune import Celune
@@ -262,7 +262,7 @@ def reset_audio_reactivity(self) -> None:
def _process_glow_chunk(self, audio: npt.NDArray[np.float32], now: float) -> None:
"""Process one audio chunk and update audio-reactive brightness."""
- del now
+ discard(now)
level = self._speech_level(audio)
smoothing = float(np.clip(self.smoothing_factor, 0.0, 0.98))
self._smoothed_level = (self._smoothed_level * smoothing) + (
diff --git a/celune/colors.py b/celune/colors.py
index ac0ca26..8420fe7 100644
--- a/celune/colors.py
+++ b/celune/colors.py
@@ -7,8 +7,8 @@
from textual.theme import Theme
-from .typing.common import RGB
from .utils import to_rgb
+from .typing.common import RGB
DEFAULT_BACKGROUND: Final[str] = "#1d1826"
DEFAULT_ACCENT: Final[str] = "#cebaff"
diff --git a/celune/config.py b/celune/config.py
index 6f1b16f..b205f81 100644
--- a/celune/config.py
+++ b/celune/config.py
@@ -3,12 +3,19 @@
import os
from copy import deepcopy
-from typing import Optional
+from typing import Optional, Union, Literal
from collections.abc import Mapping
+import sounddevice as sd
+
+from .constants import APP_NAME
+from .i18n import string
from .typing.common import Config, JSONSerializable
ENABLED_ENV_VALUES = {"1", "true", "on", "yes", "enabled"}
+AudioDeviceConfig = Optional[Union[int, str]]
+AudioDeviceDirection = Literal["input", "output"]
+AudioDeviceInfoValue = Union[bool, int, float, str]
def env_bool(name: str, fallback: bool = False) -> bool:
@@ -68,6 +75,126 @@ def config_bool(
return env_bool(env_name, bool(config_value(config, config_key, default)))
+def config_audio_device(
+ config: Optional[Mapping[str, JSONSerializable]],
+ key: str,
+) -> AudioDeviceConfig:
+ """Resolve one nullable audio-device selection from configuration.
+
+ Args:
+ config: Loaded configuration dictionary, or ``None``.
+ key: Configuration key containing the device name or index.
+
+ Returns:
+ AudioDeviceConfig: ``None`` for the system default device, or the configured device name/index.
+ """
+ value = config_value(config, key)
+ if value is None or isinstance(value, bool):
+ return None
+ if isinstance(value, int):
+ return value
+ if isinstance(value, str):
+ stripped = value.strip()
+ return stripped or None
+ return None
+
+
+def resolve_audio_device(
+ config: Optional[Mapping[str, JSONSerializable]],
+ key: str,
+ direction: AudioDeviceDirection,
+) -> AudioDeviceConfig:
+ """Resolve one configured audio device into an exact PortAudio selector.
+
+ Args:
+ config: Configuration mapping that may declare one device selector.
+ key: Config key to resolve from the mapping.
+ direction: Whether the selector is for input or output audio.
+
+ Returns:
+ AudioDeviceConfig: Exact selector data suitable for PortAudio lookup.
+ """
+ resolved, _device_info = resolve_audio_device_with_info(config, key, direction)
+ return resolved
+
+
+def resolve_audio_device_with_info(
+ config: Optional[Mapping[str, JSONSerializable]],
+ key: str,
+ direction: AudioDeviceDirection,
+) -> tuple[AudioDeviceConfig, Optional[Mapping[str, AudioDeviceInfoValue]]]:
+ """Resolve one configured audio device into an exact PortAudio selector.
+
+ Args:
+ config: Loaded configuration dictionary, or ``None``.
+ key: Configuration key containing the device name or index.
+ direction: Whether the device must support ``"input"`` or ``"output"``.
+
+ Returns:
+ tuple[AudioDeviceConfig, Optional[Mapping[str, AudioDeviceInfoValue]]]: The resolved selector and optional
+ direct device info.
+
+ Raises:
+ ValueError: The configured device name matches multiple devices.
+ """
+ configured = config_audio_device(config, key)
+ if configured is None or isinstance(configured, int):
+ return configured, None
+
+ channel_key = (
+ "max_input_channels" if direction == "input" else "max_output_channels"
+ )
+ query_kind = "input" if direction == "input" else "output"
+ try:
+ direct_info = sd.query_devices(device=configured, kind=query_kind)
+ except ValueError:
+ direct_info = None
+ else:
+ if (
+ isinstance(direct_info, Mapping)
+ and int(direct_info.get(channel_key, 0)) > 0
+ ):
+ # PortAudio already resolved this selector successfully, so reuse the
+ # returned device info and avoid a second global device scan.
+ return configured, direct_info
+
+ hostapis = sd.query_hostapis()
+ matches: list[tuple[int, str]] = []
+ all_devices = sd.query_devices()
+
+ if isinstance(all_devices, Mapping):
+ name = str(all_devices.get("name", ""))
+ if str(configured).casefold() in name.casefold():
+ if int(all_devices.get(channel_key, 0)) > 0:
+ return configured, all_devices
+ return configured, None
+
+ for index, info in enumerate(all_devices):
+ if str(configured).casefold() not in str(info["name"]).casefold():
+ continue
+ if int(info.get(channel_key, 0)) <= 0:
+ continue
+ hostapi_name = str(hostapis[int(info["hostapi"])]["name"])
+ matches.append((index, f"[{index}] {info['name']}, {hostapi_name}"))
+
+ if len(matches) == 1:
+ return matches[0][0], None
+
+ if len(matches) > 1:
+ matches_text = "\n".join(f"- {label}" for _, label in matches)
+ raise ValueError(
+ string(
+ "config.audio_device_multiple_matches",
+ device_kind=string(f"config.audio_device_kind_{query_kind}"),
+ device_name=configured,
+ matches=matches_text,
+ app_name=APP_NAME,
+ )
+ )
+
+ return configured, None
+
+
def merge_missing_defaults(
config: Optional[Mapping[str, JSONSerializable]],
defaults: Mapping[str, JSONSerializable],
diff --git a/celune/constants.py b/celune/constants.py
index 6e5ab4a..1e85c53 100644
--- a/celune/constants.py
+++ b/celune/constants.py
@@ -3,8 +3,11 @@
import signal
import datetime
+import itertools
from enum import auto, IntEnum, Enum
+from typing import Optional
+from .i18n import string
from .typing.common import JSON as _JSON
from .typing.common import JSONSerializable as _JSONSerializable
@@ -28,13 +31,35 @@
# this embedding model is used to extract a voice embedding vector out of the target utterance,
# and analyze the voice automatically based on any given embeddings from your CEVOICE/CECHAR pack
VOICE_EMBEDDING_MODEL = "marksverdhei/Qwen3-Voice-Embedding-12Hz-1.7B"
+VOICE_EMBEDDING_MODEL_REVISION = "7577f61c42737fc8064bba773e2a18602df92803"
# this embedding model is used to retrieve long-term Persona memories semantically when available
PERSONA_MEMORY_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
+# this model is used to infer conversation emotion and derive Persona's target response mood
+PERSONA_EMOTION_MODEL = "lunahr/emotispace-128"
# this model is loaded by Celune, and used to control the persona
PERSONA_MODEL_ID = "Qwen/Qwen3-VL-4B-Instruct"
+PERSONA_MODEL_REVISION = "ebb281ec70b05090aa6165b016eac8ec08e71b17"
PERSONA_HISTORY_MESSAGES = 20
+REMOTE_CODE_MODEL_REVISIONS = {
+ VOICE_EMBEDDING_MODEL: VOICE_EMBEDDING_MODEL_REVISION,
+ PERSONA_MODEL_ID: PERSONA_MODEL_REVISION,
+}
+
+
+def remote_code_model_revision(model_id: str) -> Optional[str]:
+ """Return the pinned revision for a remote-code model when Celune knows one.
+
+ Args:
+ model_id: Hugging Face model ID to resolve.
+
+ Returns:
+ Optional[str]: The pinned commit revision, or ``None`` when unknown.
+ """
+ return REMOTE_CODE_MODEL_REVISIONS.get(model_id)
+
+
# used to pre-calculate the next full moon for the glow boost
REFERENCE_NEW_MOON = datetime.datetime(2000, 1, 6, 18, 14, tzinfo=datetime.timezone.utc)
@@ -139,3 +164,12 @@ class UtteranceLoudnessTier(IntEnum):
"high": 12,
"xhigh": 16,
}
+
+CRASH_LINES = itertools.cycle(
+ [
+ string("osc.crash_1", app_name=APP_NAME),
+ string("osc.crash_2", app_name=APP_NAME),
+ string("osc.crash_3"),
+ string("osc.crash_4"),
+ ]
+)
diff --git a/celune/dataclasses/__init__.py b/celune/dataclasses/__init__.py
index 4359d32..56f3f84 100644
--- a/celune/dataclasses/__init__.py
+++ b/celune/dataclasses/__init__.py
@@ -1,7 +1,7 @@
"""Unified Celune dataclass package with lazy re-exports."""
-from importlib import import_module
from typing import TYPE_CHECKING
+from importlib import import_module
if TYPE_CHECKING:
from .celune import (
@@ -22,11 +22,14 @@
GenerateResponse,
)
from .pipeline import (
+ AudioOutput,
+ AudioInputRequest,
PlaybackChunk,
PlaybackSourceDone,
SpeechDone,
SpeechRequest,
SpeechTiming,
+ VoiceConversionRequest,
)
from .properties import (
ConstantPropertySpec,
@@ -48,6 +51,8 @@
"CelunePipelineState": "celune",
"CeluneRuntimeState": "celune",
"CeluneVoiceState": "celune",
+ "AudioOutput": "pipeline",
+ "AudioInputRequest": "pipeline",
"ChatMessage": "persona",
"ConstantPropertySpec": "properties",
"ForwardedPropertySpec": "properties",
@@ -58,6 +63,7 @@
"SpeechDone": "pipeline",
"SpeechRequest": "pipeline",
"SpeechTiming": "pipeline",
+ "VoiceConversionRequest": "pipeline",
"bind_constant_properties": "properties",
"bind_forwarded_properties": "properties",
"constant_property": "properties",
@@ -68,6 +74,8 @@
"CELUNE_CONSTANT_PROPERTIES",
"CELUNE_FORWARDED_PROPERTIES",
"CeluneAudioState",
+ "AudioOutput",
+ "AudioInputRequest",
"CeluneBackendState",
"CeluneCallbackState",
"CeluneContext",
@@ -85,6 +93,7 @@
"SpeechDone",
"SpeechRequest",
"SpeechTiming",
+ "VoiceConversionRequest",
"bind_constant_properties",
"bind_forwarded_properties",
"constant_property",
diff --git a/celune/dataclasses/celune.py b/celune/dataclasses/celune.py
index f90d498..d58999d 100644
--- a/celune/dataclasses/celune.py
+++ b/celune/dataclasses/celune.py
@@ -2,8 +2,8 @@
import queue
import threading
-from dataclasses import dataclass, field
from typing import Optional, Union
+from dataclasses import dataclass, field
import numpy as np
import numpy.typing as npt
@@ -11,15 +11,17 @@
from transformers.modeling_utils import PreTrainedModel
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
-from ..backends import CeluneBackend
-from ..cevoice import CEVoicePersona
-from ..chroma import AudioRGBGlow
from ..config import Config
-from ..constants import JSONSerializable, PipelineStates
-from ..dsp import StreamingPedalboardReverb
-from ..extensions.manager import CeluneExtensionManager
+from ..chroma import AudioRGBGlow
+from ..cevoice import CEVoicePersona
+from ..backends.tts import CeluneBackend
from ..persona.impl import PersonaClient
+from ..backends.vc import CeluneVCBackend
from ..typing.backends import BackendModel
+from ..dsp import StreamingPedalboardReverb
+from ..extensions.manager import CeluneExtensionManager
+from ..constants import JSONSerializable, PipelineStates
+from .properties import ConstantPropertySpec, ForwardedPropertySpec
from ..typing.celune import (
ErrorCallback,
IdleCallback,
@@ -27,10 +29,11 @@
MessageCallback,
ProgressCallback,
QueueAvailableCallback,
+ TTSBackendRecipe,
+ VCBackendRecipe,
VoiceChangedCallback,
VoiceLockStateCallback,
)
-from .properties import ConstantPropertySpec, ForwardedPropertySpec
@dataclass
@@ -53,10 +56,16 @@ class CeluneBackendState:
"""Backend selection and configuration state."""
config: Config
- backend_spec: Optional[Union[str, type[CeluneBackend]]] = None
+ backend_spec: Optional[TTSBackendRecipe] = None
backend_kwargs: dict[str, JSONSerializable] = field(default_factory=dict)
backend: Optional[CeluneBackend] = None
tts_backend: str = ""
+ vc_backend_spec: Optional[VCBackendRecipe] = None
+ vc_backend: Optional[CeluneVCBackend] = None
+ voice_conversion_backend: str = ""
+ vc_pitch_shift: int = 0
+ vc_f0_condition: bool = False
+ input_mode: str = "text_to_speech"
chunk_size: int = 0
language: str = "Auto"
dev: bool = False
@@ -139,6 +148,7 @@ class CeluneRuntimeState:
regenerate: bool = False
locked: bool = True
loaded: bool = False
+ reload_pending: bool = False
sleeping: bool = False
last_flavor: Optional[str] = None
ready_announced: bool = False
@@ -174,6 +184,16 @@ class CeluneRuntimeState:
ForwardedPropertySpec("_backend_kwargs", "_backend_state", "backend_kwargs"),
ForwardedPropertySpec("backend", "_backend_state", "backend"),
ForwardedPropertySpec("tts_backend", "_backend_state", "tts_backend"),
+ ForwardedPropertySpec("_vc_backend_spec", "_backend_state", "vc_backend_spec"),
+ ForwardedPropertySpec("vc_backend", "_backend_state", "vc_backend"),
+ ForwardedPropertySpec(
+ "voice_conversion_backend",
+ "_backend_state",
+ "voice_conversion_backend",
+ ),
+ ForwardedPropertySpec("vc_pitch_shift", "_backend_state", "vc_pitch_shift"),
+ ForwardedPropertySpec("vc_f0_condition", "_backend_state", "vc_f0_condition"),
+ ForwardedPropertySpec("input_mode", "_backend_state", "input_mode"),
ForwardedPropertySpec("chunk_size", "_backend_state", "chunk_size"),
ForwardedPropertySpec("language", "_backend_state", "language"),
ForwardedPropertySpec("dev", "_backend_state", "dev"),
@@ -269,6 +289,7 @@ class CeluneRuntimeState:
ForwardedPropertySpec("sleeping", "_runtime_state", "sleeping"),
ForwardedPropertySpec("_last_flavor", "_runtime_state", "last_flavor"),
ForwardedPropertySpec("_ready_announced", "_runtime_state", "ready_announced"),
+ ForwardedPropertySpec("_reload_pending", "_runtime_state", "reload_pending"),
ForwardedPropertySpec("cur_state", "_runtime_state", "cur_state"),
ForwardedPropertySpec("is_in_tutorial", "_runtime_state", "is_in_tutorial"),
ForwardedPropertySpec("extension_manager", "_runtime_state", "extension_manager"),
diff --git a/celune/dataclasses/events.py b/celune/dataclasses/events.py
new file mode 100644
index 0000000..1baeb5f
--- /dev/null
+++ b/celune/dataclasses/events.py
@@ -0,0 +1,147 @@
+"""Typed Celune lifecycle and extension event payloads."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Optional
+
+if TYPE_CHECKING:
+ from ..celune import Celune
+
+
+@dataclass(slots=True)
+class ReadyEvent:
+ """Payload emitted once Celune is fully initialized."""
+
+ celune: "Celune"
+
+
+@dataclass(slots=True)
+class ShutdownEvent:
+ """Payload emitted when Celune begins shutting down."""
+
+ celune: "Celune"
+
+
+@dataclass(slots=True)
+class FatalEvent:
+ """Payload emitted when Celune enters a fatal runtime error state."""
+
+ celune: "Celune"
+ error: Exception
+ source: str
+
+
+@dataclass(slots=True)
+class ErrorEvent:
+ """Payload emitted for non-fatal engine-level errors."""
+
+ celune: "Celune"
+ error: Exception
+ source: str
+
+
+@dataclass(slots=True)
+class VoiceChangedEvent:
+ """Payload emitted after Celune switches to a different voice."""
+
+ celune: "Celune"
+ old_voice: str
+ new_voice: str
+
+
+@dataclass(slots=True)
+class StateChangedEvent:
+ """Payload emitted whenever Celune changes runtime state."""
+
+ celune: "Celune"
+ old_state: str
+ new_state: str
+
+
+@dataclass(slots=True)
+class GenerationStartEvent:
+ """Payload emitted when a speech request starts generating."""
+
+ celune: "Celune"
+ text: str
+ display_text: str
+ save: bool
+ language: str
+
+
+@dataclass(slots=True)
+class GenerationEndEvent:
+ """Payload emitted when a speech request finishes generating."""
+
+ celune: "Celune"
+ text: str
+ display_text: str
+ save: bool
+ language: str
+ saved_path: Optional[str] = None
+
+
+@dataclass(slots=True)
+class GenerationErrorEvent:
+ """Payload emitted when generation fails for one speech request."""
+
+ celune: "Celune"
+ text: str
+ display_text: str
+ save: bool
+ language: str
+ error: Exception
+
+
+@dataclass(slots=True)
+class AudioStartEvent:
+ """Payload emitted when an audio source begins playback."""
+
+ celune: "Celune"
+ source_id: int
+ label: str
+ kind: str
+ saved_path: Optional[str] = None
+
+
+@dataclass(slots=True)
+class AudioEndEvent:
+ """Payload emitted when an audio source finishes playback."""
+
+ celune: "Celune"
+ source_id: int
+ label: str
+ kind: str
+ saved_path: Optional[str] = None
+
+
+@dataclass(slots=True)
+class CharacterLoadedEvent:
+ """Payload emitted when one CEVOICE character bundle becomes active."""
+
+ celune: "Celune"
+ character_name: str
+ bundle_path: Optional[str]
+ is_default: bool
+
+
+@dataclass(slots=True)
+class CharacterUnloadedEvent:
+ """Payload emitted when one CEVOICE character bundle is unloaded."""
+
+ celune: "Celune"
+ character_name: str
+ bundle_path: Optional[str]
+
+
+@dataclass(slots=True)
+class CharacterChangedEvent:
+ """Payload emitted when Celune switches between CEVOICE characters."""
+
+ celune: "Celune"
+ old_character: str
+ new_character: str
+ old_bundle_path: Optional[str]
+ new_bundle_path: Optional[str]
+ new_is_default: bool
diff --git a/celune/dataclasses/extensions.py b/celune/dataclasses/extensions.py
index 07afcd3..8bed8a8 100644
--- a/celune/dataclasses/extensions.py
+++ b/celune/dataclasses/extensions.py
@@ -1,6 +1,9 @@
"""Extension-facing dataclasses."""
+from pathlib import Path
from dataclasses import dataclass, field
+from contextlib import AbstractContextManager
+from typing import TYPE_CHECKING, Optional, Callable, Union
from .. import __version__
from ..typing.common import JSONSerializable
@@ -18,6 +21,9 @@
CELUNE_VERSION = __version__
+if TYPE_CHECKING:
+ from ..celune import Celune
+
@dataclass(slots=True)
class CeluneContext:
@@ -32,6 +38,10 @@ class CeluneContext:
set_voice: SetVoiceCallable
get_state: GetStateCallable
wait_until_ready: WaitUntilReadyCallable
+ backend_override: Optional[Callable[[str], AbstractContextManager["Celune"]]] = None
+ cevoice_override: Optional[
+ Callable[[Optional[Union[str, Path]]], AbstractContextManager["Celune"]]
+ ] = None
name: str = "Celune"
version: str = CELUNE_VERSION
shared: dict[str, JSONSerializable] = field(default_factory=dict)
@@ -61,3 +71,41 @@ def get(
JSONSerializable: The stored value, or ``default`` when absent.
"""
return self.shared.get(key, default)
+
+ def with_backend(
+ self,
+ backend_name: str,
+ ) -> AbstractContextManager["Celune"]:
+ """Temporarily switch Celune to another backend for one context block.
+
+ Args:
+ backend_name: The backend name to activate for the lifetime of the context.
+
+ Returns:
+ AbstractContextManager[Celune]: A context manager that restores the previous backend on exit.
+
+ Raises:
+ RuntimeError: Backend overrides are unavailable in this context.
+ """
+ if self.backend_override is None:
+ raise RuntimeError("current context can't switch backends")
+ return self.backend_override(backend_name)
+
+ def with_cevoice(
+ self,
+ bundle: Optional[Union[str, Path]],
+ ) -> AbstractContextManager["Celune"]:
+ """Temporarily switch Celune to another CEVOICE bundle for one context block.
+
+ Args:
+ bundle: The CEVOICE bundle name or path to activate temporarily.
+
+ Returns:
+ AbstractContextManager[Celune]: A context manager that restores the previous CEVOICE pack on exit.
+
+ Raises:
+ RuntimeError: CEVOICE overrides are unavailable in this context.
+ """
+ if self.cevoice_override is None:
+ raise RuntimeError("current context can't switch characters")
+ return self.cevoice_override(bundle)
diff --git a/celune/dataclasses/persona.py b/celune/dataclasses/persona.py
index dea038b..d56dce4 100644
--- a/celune/dataclasses/persona.py
+++ b/celune/dataclasses/persona.py
@@ -1,7 +1,7 @@
"""Persona runtime dataclasses."""
-from dataclasses import dataclass, field
from typing import Optional
+from dataclasses import dataclass, field
from ..typing.persona import MessageContent, Role
diff --git a/celune/dataclasses/pipeline.py b/celune/dataclasses/pipeline.py
index 193a5b4..b933a67 100644
--- a/celune/dataclasses/pipeline.py
+++ b/celune/dataclasses/pipeline.py
@@ -2,8 +2,9 @@
from __future__ import annotations
-import queue
import time
+import queue
+from pathlib import Path
from dataclasses import dataclass
from typing import Optional, Union
@@ -27,6 +28,42 @@ class SpeechRequest:
normalize: bool = False
+@dataclass(frozen=True)
+class AudioInputRequest:
+ """Engine-level audio input accepted for future non-TTS modes."""
+
+ audio: npt.NDArray[np.float32]
+ sample_rate: int
+ label: str = "audio input"
+ pitch_shift: Optional[int] = None
+ f0_condition: Optional[bool] = None
+ log_playback: bool = True
+ reset_ready_announcement: bool = True
+
+
+@dataclass(frozen=True)
+class VoiceConversionRequest:
+ """Audio input plus target voice metadata for voice conversion backends."""
+
+ source_audio: npt.NDArray[np.float32]
+ sample_rate: int
+ target_voice: Optional[str] = None
+ target_character: Optional[str] = None
+ target_references: tuple[Path, ...] = ()
+ label: str = "audio input"
+ pitch_shift: Optional[int] = None
+ f0_condition: Optional[bool] = None
+
+
+@dataclass(frozen=True)
+class AudioOutput:
+ """Decoded playable audio returned by speech or conversion pipelines."""
+
+ audio: npt.NDArray[np.float32]
+ sample_rate: int
+ label: str = "audio output"
+
+
@dataclass(frozen=True)
class SpeechDone:
"""Playback completion marker for one generated utterance."""
@@ -51,6 +88,7 @@ class PlaybackSourceDone:
source_id: int
release_pipeline: bool = False
+ notify_idle: bool = True
saved_path: Optional[str] = None
analysis_audio: Optional[npt.NDArray[np.float32]] = None
@@ -63,10 +101,16 @@ class SpeechTiming:
first_chunk_time: Optional[float] = None
first_playback_time: Optional[float] = None
- def mark_first_chunk(self) -> None:
- """Record when the backend yields its first audio chunk."""
+ def mark_first_chunk(self, chunk_time: Optional[float] = None) -> None:
+ """Record when the backend produces its first audio chunk.
+
+ Args:
+ chunk_time: Optional backend-provided monotonic timestamp to use.
+ """
if self.first_chunk_time is None:
- self.first_chunk_time = time.monotonic()
+ self.first_chunk_time = (
+ chunk_time if isinstance(chunk_time, float) else time.monotonic()
+ )
def mark_first_playback(self) -> None:
"""Record when the first audio chunk is sent to the output stream."""
diff --git a/celune/dataclasses/properties.py b/celune/dataclasses/properties.py
index bb325e3..17c8633 100644
--- a/celune/dataclasses/properties.py
+++ b/celune/dataclasses/properties.py
@@ -1,12 +1,12 @@
"""Property helpers for grouped Celune runtime state."""
-from dataclasses import dataclass
from enum import Enum
+from dataclasses import dataclass
from typing import Optional, Union
from ..typing.common import JSONSerializable
-ConstantPropertyValue = Union[JSONSerializable, Enum]
+type ConstantPropertyValue = Union[JSONSerializable, Enum]
@dataclass(frozen=True, slots=True)
diff --git a/celune/dsp.py b/celune/dsp.py
index f8ba6d2..81af75c 100644
--- a/celune/dsp.py
+++ b/celune/dsp.py
@@ -79,6 +79,31 @@ def _pitch_shift_ui_signal(
return np.ascontiguousarray(shifted, dtype=np.float32)
+def pitch_shift_audio(
+ audio: npt.NDArray[np.float32],
+ sample_rate: int,
+ n_steps: float,
+) -> npt.NDArray[np.float32]:
+ """Shift audio pitch while preserving tempo at the given sample rate.
+
+ Args:
+ audio: Input waveform to shift, provided as a float32 NumPy array.
+ sample_rate: Sample rate associated with ``audio``.
+ n_steps: Number of semitones to shift the signal up or down.
+
+ Returns:
+ npt.NDArray[np.float32]: A contiguous float32 waveform with pitch shifted and tempo preserved.
+ """
+ if n_steps == 0:
+ return np.ascontiguousarray(audio, dtype=np.float32)
+
+ shifted = Pedalboard([PitchShift(semitones=n_steps)])(
+ np.asarray(audio, dtype=np.float32),
+ sample_rate,
+ )
+ return np.ascontiguousarray(shifted, dtype=np.float32)
+
+
def _freeze_signal(audio: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]:
"""Return one shared read-only buffer for a cached UI signal."""
frozen = np.ascontiguousarray(audio, dtype=np.float32)
diff --git a/celune/entrypoint.py b/celune/entrypoint.py
index b355a3e..e265b53 100644
--- a/celune/entrypoint.py
+++ b/celune/entrypoint.py
@@ -18,10 +18,11 @@
from dataclasses import dataclass
from types import SimpleNamespace, ModuleType
+from celune.updater import apply_update_and_restart
from celune import __version__, REVISION, __tagline__
-from celune.constants import APP_NAME, APP_SLUG, ExitCodes
from celune.paths import project_root, running_compiled
-from celune.updater import apply_update_and_restart
+from celune.constants import APP_NAME, APP_SLUG, ExitCodes
+from celune.i18n import string
def _env_flag(name: str) -> bool:
@@ -45,6 +46,12 @@ def _env_flag(name: str) -> bool:
_RUNTIME: Optional[SimpleNamespace] = None
+def _load_ui_test_backend() -> type:
+ """Load the lightweight fake backend used by the explicit UI test mode."""
+ support = importlib.import_module("tests.support")
+ return support.FakeBackend
+
+
def normalize_argv0(argv: Optional[list[str]] = None) -> list[str]:
"""Return argv with the launcher-facing program name normalized when applicable.
@@ -67,25 +74,25 @@ def _display_version() -> tuple[str, str]:
def _print_dependency_setup_help(package_name: str) -> None:
"""Print the shared missing-dependency guidance used by startup paths."""
- print(f"You do not have '{package_name}' installed.")
- print(f"{APP_NAME} requires this library to function.")
+ print(string("cli.dependency_missing", package_name=package_name))
+ print(string("cli.dependency_required", app_name=APP_NAME))
print()
- print(f"Set up {APP_NAME} automatically by running:")
- print(" python setup.py")
+ print(string("cli.setup_automatically", app_name=APP_NAME))
+ print(string("cli.setup_cmd_setup_py"))
print()
- print("or alternatively with uv:")
- print(" uv sync")
+ print(string("cli.setup_with_uv"))
+ print(string("cli.setup_cmd_uv_sync"))
print()
- print("or install the package manually:")
- print(f" pip install {package_name}")
+ print(string("cli.install_manually"))
+ print(string("cli.setup_cmd_pip_install", package_name=package_name))
print()
- print("for full traceback:")
+ print(string("cli.full_traceback"))
if os.name == "nt":
- print("set CELUNE_DEV=1")
- print(f" python {SCRIPT_NAME}")
+ print(string("cli.traceback_cmd_set_dev"))
+ print(string("cli.traceback_cmd_python", script_name=SCRIPT_NAME))
else:
- print(f" CELUNE_DEV=1 python {SCRIPT_NAME}")
+ print(string("cli.traceback_cmd_dev_python", script_name=SCRIPT_NAME))
print()
@@ -836,12 +843,12 @@ def run_doctor(argv: list[str]) -> int:
if doctor_args == ["--fix"]:
fix = True
else:
- print(f"Usage: {argv[0]} doctor [--fix]")
- print(f"Inspect {APP_NAME}'s runtime environment without starting the app.")
- print("Use --fix to run setup.py from the repository root.")
+ print(string("cli.doctor_usage", program=argv[0]))
+ print(string("cli.doctor_description", app_name=APP_NAME))
+ print(string("cli.doctor_fix_help"))
return EXIT_CODES.EXIT_UNKNOWN_ARGS.value
- print(f"Checking your environment for compatibility with {APP_NAME}...")
+ print(string("cli.doctor_checking", app_name=APP_NAME))
print()
checks = _doctor_checks()
@@ -853,7 +860,7 @@ def run_doctor(argv: list[str]) -> int:
status = _doctor_status(check)
print(f"[{status}] {check.label}: {check.detail}")
if check.hint and not check.ok:
- print(f" hint: {check.hint}")
+ print(f" {string('cli.doctor_hint', hint=check.hint)}")
if check.ok:
passes += 1
@@ -864,29 +871,30 @@ def run_doctor(argv: list[str]) -> int:
print()
print(
- f"Summary: {passes} passed, {warnings_count} warning"
- f"{'' if warnings_count == 1 else 's'}, {failures} failed"
+ string(
+ "cli.doctor_summary",
+ passes=passes,
+ warnings_count=warnings_count,
+ warning_suffix="" if warnings_count == 1 else "s",
+ failures=failures,
+ )
)
print()
if failures == 0 and warnings_count == 0:
- print(f"Your system is ready to run {APP_NAME}.")
+ print(string("cli.doctor_ready", app_name=APP_NAME))
elif failures == 0 and warnings_count > 0:
- print(f"{APP_NAME}'s performance may be impacted.")
- print("Rerun with --fix to attempt to fix some of these problems.")
- print(
- "Please note that this will not fix a Python version incompatibility or lack of a CUDA runtime."
- )
+ print(string("cli.doctor_performance_impacted", app_name=APP_NAME))
+ print(string("cli.doctor_rerun_fix"))
+ print(string("cli.doctor_fix_limits"))
else:
- print(f"{APP_NAME} will not work.")
- print("Rerun with --fix to attempt to fix some of these problems.")
- print(
- "Please note that this will not fix a Python version incompatibility or lack of a CUDA runtime."
- )
+ print(string("cli.doctor_will_not_work", app_name=APP_NAME))
+ print(string("cli.doctor_rerun_fix"))
+ print(string("cli.doctor_fix_limits"))
if fix:
print()
- print("Attempting to fix fixable problems...")
+ print(string("cli.doctor_attempting_fix"))
try:
result = subprocess.run(
[str(_doctor_subprocess_python()), str(SETUP_PATH)],
@@ -894,7 +902,7 @@ def run_doctor(argv: list[str]) -> int:
check=False,
)
except OSError as exc:
- print(f"Failed to fix fixable problems: {exc}")
+ print(string("cli.fix_failed", error=exc))
return EXIT_CODES.EXIT_FAILURE.value
return result.returncode
@@ -913,75 +921,83 @@ def handle_config(command_args: list[str], prog_name: str) -> None:
if len(command_args) == 1:
if command_args[0] == "view":
if not runtime.config_path().exists():
- print(f"{APP_NAME} configuration has not been created yet.")
- print(f"Run {APP_NAME} at least once to create it.")
+ print(string("cli.config_not_created", app_name=APP_NAME))
+ print(string("cli.run_once_to_create_config", app_name=APP_NAME))
sys.exit(EXIT_CODES.EXIT_FAILURE.value)
- print(f"Current {APP_NAME} configuration:")
+ print(string("cli.current_config", app_name=APP_NAME))
print()
try:
with open(runtime.config_path(), encoding="utf-8") as cfg:
print(cfg.read().strip())
except PermissionError:
- print(f"{APP_NAME} configuration could not be read.")
+ print(string("cli.config_could_not_be_read", app_name=APP_NAME))
sys.exit(EXIT_CODES.EXIT_FAILURE.value)
elif command_args[0] == "edit":
if not runtime.config_path().exists():
- print(f"{APP_NAME} configuration has not been created yet.")
- print(f"Run {APP_NAME} at least once to create it.")
+ print(string("cli.config_not_created", app_name=APP_NAME))
+ print(string("cli.run_once_to_create_config", app_name=APP_NAME))
sys.exit(EXIT_CODES.EXIT_FAILURE.value)
try:
runtime.webbrowser.open(runtime.config_path())
except PermissionError:
- print(f"{APP_NAME} configuration could not be read.")
+ print(string("cli.config_could_not_be_read", app_name=APP_NAME))
sys.exit(EXIT_CODES.EXIT_FAILURE.value)
else:
- print("Invalid argument.")
+ print(string("cli.invalid_argument"))
print()
- print(f"Usage: {prog_name} config [view/edit]")
- print(f"View or edit {APP_NAME}'s configuration.")
+ print(string("cli.config_usage", program=prog_name))
+ print(string("cli.config_description", app_name=APP_NAME))
sys.exit(EXIT_CODES.EXIT_UNKNOWN_ARGS.value)
elif len(command_args) > 1:
- print("Too many arguments.")
+ print(string("cli.too_many_arguments"))
print()
- print(f"Usage: {prog_name} config [view/edit]")
- print(f"View or edit {APP_NAME}'s configuration.")
+ print(string("cli.config_usage", program=prog_name))
+ print(string("cli.config_description", app_name=APP_NAME))
sys.exit(EXIT_CODES.EXIT_UNKNOWN_ARGS.value)
else:
- print("No argument given.")
+ print(string("cli.no_argument_given"))
print()
- print(f"Usage: {prog_name} config [view/edit]")
- print(f"View or edit {APP_NAME}'s configuration.")
+ print(string("cli.config_usage", program=prog_name))
+ print(string("cli.config_description", app_name=APP_NAME))
sys.exit(EXIT_CODES.EXIT_UNKNOWN_ARGS.value)
-def start(verbose: bool = False) -> None:
+def start(verbose: bool = False, testing: bool = False) -> None:
"""Instantiate and start the app.
Args:
verbose: Whether the app should be started in verbose (developer) mode.
+ testing: Whether the app should be started in UI test mode.
Raises:
No: Raised on Celune's name day unless explicitly overridden.
Exception: Re-raised after printing a traceback in developer mode.
"""
runtime = _load_runtime()
+
try:
+ if testing:
+ ui = runtime.CeluneUI()
+ celune = runtime.Celune(config={}, backend=_load_ui_test_backend())
+ ui.celune = celune
+ ui.run()
+ sys.exit(EXIT_CODES.EXIT_SUCCESS.value)
+ if runtime.supports_ansi():
+ sys.stdout.write(f"\x1b]2;{string('osc.starting', app_name=APP_NAME)}\x07")
+ sys.stdout.flush()
date = datetime.datetime.now()
if runtime.has_name_day("Celine", date) and not runtime.env_bool(
"CELUNE_OVERRIDE_CELINE_DAY"
):
raise runtime.No
- if runtime.supports_ansi():
- print(f"\x1b]2;{APP_NAME}\x07", end="", flush=True)
-
ide = runtime.detected_ide()
if ide is not None:
- print(f"{APP_NAME} is running from {ide}.")
- print("Some IDE terminals may behave differently from a normal terminal.")
+ print(string("cli.running_from_ide", app_name=APP_NAME, ide=ide))
+ print(string("cli.ide_terminals_differ"))
print()
time.sleep(5)
@@ -991,7 +1007,7 @@ def start(verbose: bool = False) -> None:
default_path=bundled_default_config_path,
)
if created_config:
- print(f"{APP_NAME} configuration has been created.")
+ print(string("cli.config_created", app_name=APP_NAME))
with open(active_config_path, encoding="utf-8") as cfg:
config = runtime.yaml.safe_load(cfg)
@@ -1007,7 +1023,7 @@ def start(verbose: bool = False) -> None:
if config_updated:
with open(active_config_path, "w", encoding="utf-8") as cfg:
runtime.yaml.safe_dump(config, cfg, sort_keys=False)
- print(f"{APP_NAME} configuration has been updated with new defaults.")
+ print(string("cli.config_updated_defaults", app_name=APP_NAME))
dev = verbose or runtime.config_bool(config, "CELUNE_DEV", "dev")
headless = runtime.config_bool(config, "CELUNE_HEADLESS", "headless")
@@ -1020,22 +1036,27 @@ def start(verbose: bool = False) -> None:
latest_label = f"{APP_NAME} {update.latest_version}"
if not update.latest_tag:
warnings.warn(
- "update information is incomplete", RuntimeWarning, stacklevel=2
+ string("cli.update_info_incomplete"),
+ RuntimeWarning,
+ stacklevel=2,
)
latest_label = APP_NAME
choice = runtime.SelectMenu(
- ["Yes, update now", "No, continue as is"],
+ [string("cli.update_choice_yes"), string("cli.update_choice_no")],
[True, False],
"\n".join(
[
- "New update found.",
- (
- f"You are running {APP_NAME} {update.local_version} "
- f"({update.local_revision}), latest version is "
- f"{latest_label} ({update.latest_revision})."
+ string("cli.update_found"),
+ string(
+ "cli.update_version_summary",
+ app_name=APP_NAME,
+ local_version=update.local_version,
+ local_revision=update.local_revision,
+ latest_label=latest_label,
+ latest_revision=update.latest_revision,
),
- "Do you want to update?",
+ string("cli.update_prompt"),
]
),
).start()
@@ -1043,55 +1064,58 @@ def start(verbose: bool = False) -> None:
if choice:
if running_compiled():
print(
- f"{APP_NAME} will close so the launcher can apply the latest artifact."
+ string(
+ "cli.launcher_apply_artifact",
+ app_name=APP_NAME,
+ )
)
time.sleep(2)
sys.exit(runtime.ExitCodes.EXIT_PENDING_UPDATE.value)
- print(f"Updating {APP_NAME}...")
+ print(string("cli.updating", app_name=APP_NAME))
try:
runtime.update_to_latest()
except runtime.UpdateError as exc:
detail = runtime.title_case(str(exc))
- print(f"{APP_NAME} could not update: {detail}")
- print("Continuing with the current version.")
- time.sleep(5)
- else:
print(
- f"{APP_NAME} updated successfully. Restart {APP_NAME} to apply changes."
+ string(
+ "cli.update_failed", app_name=APP_NAME, detail=detail
+ )
)
+ print(string("cli.continuing_current_version"))
+ time.sleep(5)
+ else:
+ print(string("cli.update_success_restart", app_name=APP_NAME))
time.sleep(5)
sys.exit(runtime.ExitCodes.EXIT_PENDING_UPDATE.value)
elif runtime.check_for_update() and not runtime.supports_ansi():
- print("This terminal does not support ANSI.")
+ print(string("cli.no_ansi"))
if running_compiled():
- print("Requesting the launcher to refresh the packaged binaries...")
+ print(string("cli.request_refresh_binaries"))
time.sleep(2)
sys.exit(runtime.ExitCodes.EXIT_PENDING_UPDATE.value)
- print("Attempting to apply update non-interactively...")
+ print(string("cli.apply_update_noninteractive"))
try:
runtime.update_to_latest()
except runtime.UpdateError as exc:
detail = runtime.title_case(str(exc))
- print(f"{APP_NAME} could not update: {detail}")
- print("Continuing with the current version.")
+ print(string("cli.update_failed", app_name=APP_NAME, detail=detail))
+ print(string("cli.continuing_current_version"))
time.sleep(5)
else:
- print(
- f"{APP_NAME} updated successfully. Restart {APP_NAME} to apply changes."
- )
+ print(string("cli.update_success_restart", app_name=APP_NAME))
time.sleep(5)
sys.exit(runtime.ExitCodes.EXIT_PENDING_UPDATE.value)
if not runtime.env_bool("CELUNE_LAUNCHER"):
launcher_exe = "celune.exe" if os.name == "nt" else "celune.appimage"
- print(f"{APP_NAME} is not being launched via the {APP_NAME} launcher.")
+ print(string("cli.not_via_launcher", app_name=APP_NAME))
print()
- print(f"To suppress this message, run {APP_NAME} with:")
+ print(string("cli.suppress_message_run_with", app_name=APP_NAME))
print(runtime.indent(f"{launcher_exe}", spaces=4))
print()
- print("or set the following environment variable:")
+ print(string("cli.or_set_env_var"))
print(runtime.indent("CELUNE_LAUNCHER=1", spaces=4))
time.sleep(5)
else:
@@ -1105,7 +1129,7 @@ def start(verbose: bool = False) -> None:
if proc.name() in ["celune.exe", "celune.appimage"]:
active_processes += 1
if active_processes > 1:
- print(f"{APP_NAME} is already running.")
+ print(string("cli.already_running", app_name=APP_NAME))
time.sleep(5)
sys.exit(runtime.ExitCodes.EXIT_ALREADY_RUNNING.value)
@@ -1139,21 +1163,19 @@ def start(verbose: bool = False) -> None:
ui_headless.celune = celune
if not celune.load():
- print(f"{APP_NAME} could not initialize.")
+ print(string("cli.could_not_initialize", app_name=APP_NAME))
celune.close()
time.sleep(5)
sys.exit(runtime.ExitCodes.EXIT_FAILURE.value)
- print(f"{APP_NAME} is running in headless mode.")
- print(
- f"While in this mode, input is only possible via {APP_NAME} extensions."
- )
+ print(string("cli.running_headless", app_name=APP_NAME))
+ print(string("cli.headless_extensions_only", app_name=APP_NAME))
ui_headless.run()
else:
- print("This terminal does not support ANSI.")
- print(f"{APP_NAME} cannot start in normal mode.")
- print("Hint:")
- print(runtime.indent("Try using another terminal application.", spaces=4))
+ print(string("cli.no_ansi"))
+ print(string("cli.cannot_start_normal_mode", app_name=APP_NAME))
+ print(string("cli.hint"))
+ print(runtime.indent(string("cli.try_another_terminal"), spaces=4))
time.sleep(5)
sys.exit(runtime.ExitCodes.EXIT_NO_ANSI.value)
except Exception as exc:
@@ -1163,7 +1185,7 @@ def start(verbose: bool = False) -> None:
sys.stdout = stdout
sys.stderr = stderr
- print(f"An internal error occurred while {APP_NAME} was running.")
+ print(string("cli.internal_error_running", app_name=APP_NAME))
if INITIAL_DEV:
with contextlib.suppress(ModuleNotFoundError):
from rich.traceback import install
@@ -1171,24 +1193,39 @@ def start(verbose: bool = False) -> None:
install()
raise
- print(exc or "no error description")
- print("For full traceback:")
+ print(str(exc) or string("cli.no_error_description"))
+ print(string("cli.full_traceback_title"))
if os.name == "nt":
- print("set CELUNE_DEV=1")
- print(runtime.indent(f"python {SCRIPT_NAME}", spaces=4))
+ print(string("cli.traceback_cmd_set_dev"))
+ print(
+ runtime.indent(
+ string(
+ "cli.traceback_cmd_python", script_name=SCRIPT_NAME
+ ).strip(),
+ spaces=4,
+ )
+ )
else:
- print(runtime.indent(f"CELUNE_DEV=1 python {SCRIPT_NAME}", spaces=4))
+ print(
+ runtime.indent(
+ string(
+ "cli.traceback_cmd_dev_python",
+ script_name=SCRIPT_NAME,
+ ).strip(),
+ spaces=4,
+ )
+ )
print()
- print("additional debugging:")
- print(runtime.indent("Set 'dev: true' in config.yaml", spaces=4))
+ print(string("cli.additional_debugging"))
+ print(runtime.indent(string("cli.set_dev_true"), spaces=4))
sys.exit(runtime.ExitCodes.EXIT_FAILURE.value)
- print("I sense the presence of... her.")
- print("I would rather not.")
+ print(string("cli.celine_day_1"))
+ print(string("cli.celine_day_2"))
print()
- print("Hint:")
- print(runtime.indent("Try again tomorrow.", spaces=4))
- print("or set the following environment variable:")
+ print(string("cli.hint"))
+ print(runtime.indent(string("cli.try_again_tomorrow"), spaces=4))
+ print(string("cli.or_set_env_var"))
print(runtime.indent("CELUNE_OVERRIDE_CELINE_DAY=1", spaces=4))
time.sleep(5)
sys.exit(
@@ -1209,77 +1246,81 @@ def main(argv: Optional[list[str]] = None) -> None:
if args and args[0] == "__apply_update":
if len(args) < 3:
- print("Usage: celune __apply_update [args...]")
+ print(string("cli.apply_update_usage"))
sys.exit(EXIT_CODES.EXIT_UNKNOWN_ARGS.value)
try:
parent_pid = int(args[1])
except ValueError:
- print("Invalid launcher PID.")
+ print(string("cli.invalid_launcher_pid"))
sys.exit(EXIT_CODES.EXIT_UNKNOWN_ARGS.value)
launcher_path = Path(args[2]).resolve()
try:
sys.exit(apply_update_and_restart(parent_pid, launcher_path, args[3:]))
except Exception as exc:
- print(f"{APP_NAME} could not apply the launcher update: {exc}")
+ print(
+ string(
+ "cli.apply_launcher_update_failed",
+ app_name=APP_NAME,
+ error=exc,
+ )
+ )
sys.exit(EXIT_CODES.EXIT_FAILURE.value)
if not args:
start()
elif args[0] in {"start", "run"}:
- if args[1] not in {"--verbose", "-v"}:
- print("Invalid argument.")
+ allowed_args = {"--verbose", "-v", "--test", "-t"}
+ if any(arg not in allowed_args for arg in args[1:]):
+ print(string("cli.invalid_argument"))
print()
- print(f"Usage: {resolved_argv[0]} {args[0]}")
- print(f"Start {APP_NAME}.")
+ print(string("cli.start_usage", program=resolved_argv[0], command=args[0]))
+ print(string("cli.start_description", app_name=APP_NAME))
sys.exit(EXIT_CODES.EXIT_UNKNOWN_ARGS.value)
verbose = any(arg in {"--verbose", "-v"} for arg in args[1:])
- start(verbose=verbose)
+ testing = any(arg in {"--test", "-t"} for arg in args[1:])
+ start(verbose=verbose, testing=testing)
elif args[0] == "config":
handle_config(args[1:], resolved_argv[0])
elif args[0] == "doctor":
if len(args) > 1:
if args[1] != "--fix":
- print("Invalid argument.")
+ print(string("cli.invalid_argument"))
print()
- print(f"Usage: {resolved_argv[0]} doctor")
- print(f"Inspect the environment without starting {APP_NAME}.")
+ print(string("cli.doctor_usage", program=resolved_argv[0]))
+ print(string("cli.doctor_description", app_name=APP_NAME))
sys.exit(EXIT_CODES.EXIT_UNKNOWN_ARGS.value)
sys.exit(run_doctor(resolved_argv))
elif args[0] in {"help", "--help", "-h"}:
if len(args) > 1:
- print("Too many arguments.")
+ print(string("cli.too_many_arguments"))
print()
- print(f"Usage: {resolved_argv[0]} help")
- print("Display this help message.")
+ print(string("cli.help_usage", program=resolved_argv[0]))
+ print(string("cli.help_description"))
sys.exit(EXIT_CODES.EXIT_UNKNOWN_ARGS.value)
# HACK: tabs are a quick and dirty alignment trick
# they are not guaranteed to work in all terminals equally well
- print(f"Usage: {resolved_argv[0]} [command]")
+ print(string("cli.help_main_usage", program=resolved_argv[0]))
print()
- print("Available commands:")
- print(f"start/run [-v]\t\t\tStart {APP_NAME}.")
- print(f"config [view/edit]\t\tView or edit {APP_NAME}'s configuration.")
- print(
- f"doctor [--fix]\t\t\tInspect the environment without starting {APP_NAME}."
- )
- print("help\t\t\t\tDisplay this help message.")
- print(f"version\t\t\t\tDisplay running {APP_NAME} version.")
+ print(string("cli.help_available_commands"))
+ print(string("cli.help_start", app_name=APP_NAME))
+ print(string("cli.help_config", app_name=APP_NAME))
+ print(string("cli.help_doctor", app_name=APP_NAME))
+ print(string("cli.help_help"))
+ print(string("cli.help_version", app_name=APP_NAME))
print()
- print(
- f"Some commands may be used with a parameter (e.g. {resolved_argv[0]} --argument),"
- )
- print(f"or with a subcommand (e.g. {resolved_argv[0]} argument).")
+ print(string("cli.help_parameter_note", program=resolved_argv[0]))
+ print(string("cli.help_subcommand_note", program=resolved_argv[0]))
print()
- print(f"Providing no arguments implicitly defaults to starting {APP_NAME}.")
+ print(string("cli.help_default_start", app_name=APP_NAME))
elif args[0] in {"version", "--version", "-v"}:
if len(args) > 1:
- print("Too many arguments.")
+ print(string("cli.too_many_arguments"))
print()
- print(f"Usage: {resolved_argv[0]} version")
- print(f"Display running {APP_NAME} version.")
+ print(string("cli.version_usage", program=resolved_argv[0]))
+ print(string("cli.version_description", app_name=APP_NAME))
sys.exit(EXIT_CODES.EXIT_UNKNOWN_ARGS.value)
version, revision = _display_version()
@@ -1293,8 +1334,8 @@ def main(argv: Optional[list[str]] = None) -> None:
if "dirty" in revision:
print()
- print(f"Note: This is a modified version of {APP_NAME}.")
+ print(string("cli.modified_version_note", app_name=APP_NAME))
else:
- print("Unknown command or argument.")
- print(f"Run `{resolved_argv[0]} help` to list available commands.")
+ print(string("cli.unknown_command_or_argument"))
+ print(string("cli.run_help_hint", program=resolved_argv[0]))
sys.exit(EXIT_CODES.EXIT_UNKNOWN_ARGS.value)
diff --git a/celune/extensions/base.py b/celune/extensions/base.py
index 3d53dba..0835551 100644
--- a/celune/extensions/base.py
+++ b/celune/extensions/base.py
@@ -2,10 +2,11 @@
"""Celune's extension annotations and classes."""
from abc import ABC
-from typing import Optional
+from pathlib import Path
+from typing import Optional, Union
-from ..dataclasses.extensions import CeluneContext
from ..exceptions import IncompleteExtensionError
+from ..dataclasses.extensions import CeluneContext
class CeluneExtension(ABC):
@@ -36,7 +37,7 @@ def state(self) -> str:
return self.ctx.get_state()
def autostart(self) -> None:
- """Run extension startup logic."""
+ """Run deprecated extension startup logic."""
self.log(f"{self.name} has no autostart, skipping", "warning")
def invoke(self, *args, **kwargs) -> None:
@@ -140,3 +141,25 @@ def set_voice(self, voice: str) -> bool:
return False
return self.ctx.set_voice(voice)
+
+ def with_backend(self, backend_name: str):
+ """Temporarily switch Celune to another backend inside a ``with`` block.
+
+ Args:
+ backend_name: The backend name to activate temporarily.
+
+ Returns:
+ contextlib.AbstractContextManager[None]: A context manager that restores the previous backend on exit.
+ """
+ return self.ctx.with_backend(backend_name)
+
+ def with_cevoice(self, bundle: Optional[Union[str, Path]]):
+ """Temporarily switch Celune to another CEVOICE bundle inside a ``with`` block.
+
+ Args:
+ bundle: The CEVOICE bundle name or path to activate temporarily.
+
+ Returns:
+ contextlib.AbstractContextManager[None]: A context manager that restores the previous CEVOICE pack on exit.
+ """
+ return self.ctx.with_cevoice(bundle)
diff --git a/celune/extensions/events.py b/celune/extensions/events.py
new file mode 100644
index 0000000..ae3764a
--- /dev/null
+++ b/celune/extensions/events.py
@@ -0,0 +1,686 @@
+# SPDX-License-Identifier: MIT
+"""Celune's internal extension event dispatcher and decorators."""
+
+from __future__ import annotations
+
+import threading
+from dataclasses import dataclass
+from collections import defaultdict
+from typing import TYPE_CHECKING, Callable, Literal, Optional, TypeVar, cast, overload
+
+from ..typing.events import (
+ AudioEndEventCallback,
+ AudioStartEventCallback,
+ CharacterChangedEventCallback,
+ CharacterLoadedEventCallback,
+ CharacterUnloadedEventCallback,
+ ErrorEventCallback,
+ EventCallback,
+ EventName,
+ EventPayload,
+ FatalEventCallback,
+ GenerationEndEventCallback,
+ GenerationErrorEventCallback,
+ GenerationStartEventCallback,
+ ReadyEventCallback,
+ ShutdownEventCallback,
+ StateChangedEventCallback,
+ VoiceChangedEventCallback,
+)
+from ..utils import format_error
+
+if TYPE_CHECKING:
+ from ..dataclasses.events import (
+ AudioEndEvent,
+ AudioStartEvent,
+ CharacterChangedEvent,
+ CharacterLoadedEvent,
+ CharacterUnloadedEvent,
+ ErrorEvent,
+ FatalEvent,
+ GenerationEndEvent,
+ GenerationErrorEvent,
+ GenerationStartEvent,
+ ReadyEvent,
+ ShutdownEvent,
+ StateChangedEvent,
+ VoiceChangedEvent,
+ )
+
+
+EVENT_NAMES: tuple[EventName, ...] = (
+ "ready",
+ "shutdown",
+ "fatal",
+ "error",
+ "voice_changed",
+ "state_changed",
+ "generation_start",
+ "generation_end",
+ "generation_error",
+ "audio_start",
+ "audio_end",
+ "character_changed",
+ "character_loaded",
+ "character_unloaded",
+)
+_EVENT_NAME_SET = frozenset(EVENT_NAMES)
+EVENT_HANDLER_METADATA_ATTR = "__celune_event_subscriptions__"
+_DecoratedCallback = TypeVar("_DecoratedCallback", bound=Callable[..., None])
+_DispatcherCallback = Callable[[EventPayload], None]
+
+
+@dataclass(frozen=True)
+class EventSubscription:
+ """Declared event subscription metadata stored on callbacks."""
+
+ event_name: EventName
+ enabled: bool
+
+
+@dataclass(frozen=True)
+class RegisteredEventHandler:
+ """Stored event-subscription metadata used for later cleanup."""
+
+ event_name: EventName
+ callback: EventCallback
+ owner_name: str
+
+
+class EventDispatcher:
+ """Internal event dispatcher for Celune extension callbacks."""
+
+ def __init__(
+ self,
+ *,
+ log_warning: Callable[[str, str], None],
+ dev: bool = False,
+ ) -> None:
+ self._log_warning = log_warning
+ self._dev = dev
+ self._lock = threading.RLock()
+ self._callbacks: dict[EventName, list[_DispatcherCallback]] = defaultdict(list)
+ self._owners: dict[tuple[EventName, _DispatcherCallback], str] = {}
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["ready"],
+ callback: ReadyEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["shutdown"],
+ callback: ShutdownEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["fatal"],
+ callback: FatalEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["error"],
+ callback: ErrorEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["voice_changed"],
+ callback: VoiceChangedEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["state_changed"],
+ callback: StateChangedEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["generation_start"],
+ callback: GenerationStartEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["generation_end"],
+ callback: GenerationEndEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["generation_error"],
+ callback: GenerationErrorEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["audio_start"],
+ callback: AudioStartEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["audio_end"],
+ callback: AudioEndEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["character_changed"],
+ callback: CharacterChangedEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["character_loaded"],
+ callback: CharacterLoadedEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: Literal["character_unloaded"],
+ callback: CharacterUnloadedEventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ @overload
+ def subscribe(
+ self,
+ event_name: EventName,
+ callback: EventCallback,
+ owner_name: Optional[str] = None,
+ ) -> None: ...
+
+ def subscribe(
+ self,
+ event_name: EventName,
+ callback: Callable[..., None],
+ owner_name: Optional[str] = None,
+ ) -> None:
+ """Register one callback for an event.
+
+ Args:
+ event_name: Event name to subscribe to.
+ callback: Callback invoked for matching event payloads.
+ owner_name: Optional display name used in failure logs.
+ """
+ self._validate_event_name(event_name)
+ with self._lock:
+ callbacks = self._callbacks[event_name]
+ typed_callback = cast(_DispatcherCallback, callback)
+ if typed_callback not in callbacks:
+ callbacks.append(typed_callback)
+ self._owners[(event_name, typed_callback)] = (
+ owner_name or self._describe_callback(typed_callback)
+ )
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["ready"],
+ callback: ReadyEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["shutdown"],
+ callback: ShutdownEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["fatal"],
+ callback: FatalEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["error"],
+ callback: ErrorEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["voice_changed"],
+ callback: VoiceChangedEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["state_changed"],
+ callback: StateChangedEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["generation_start"],
+ callback: GenerationStartEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["generation_end"],
+ callback: GenerationEndEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["generation_error"],
+ callback: GenerationErrorEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["audio_start"],
+ callback: AudioStartEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["audio_end"],
+ callback: AudioEndEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["character_changed"],
+ callback: CharacterChangedEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["character_loaded"],
+ callback: CharacterLoadedEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: Literal["character_unloaded"],
+ callback: CharacterUnloadedEventCallback,
+ ) -> None: ...
+
+ @overload
+ def unsubscribe(
+ self,
+ event_name: EventName,
+ callback: EventCallback,
+ ) -> None: ...
+
+ def unsubscribe(
+ self,
+ event_name: EventName,
+ callback: Callable[..., None],
+ ) -> None:
+ """Unregister one callback for an event.
+
+ Args:
+ event_name: Event name to remove the callback from.
+ callback: Previously registered callback to remove.
+ """
+ self._validate_event_name(event_name)
+ with self._lock:
+ callbacks = self._callbacks.get(event_name)
+ if callbacks is None:
+ return
+ typed_callback = cast(_DispatcherCallback, callback)
+ try:
+ callbacks.remove(typed_callback)
+ except ValueError:
+ return
+ self._owners.pop((event_name, typed_callback), None)
+ if not callbacks:
+ self._callbacks.pop(event_name, None)
+
+ @overload
+ def emit(self, event_name: Literal["ready"], event: "ReadyEvent") -> None: ...
+
+ @overload
+ def emit(self, event_name: Literal["shutdown"], event: "ShutdownEvent") -> None: ...
+
+ @overload
+ def emit(self, event_name: Literal["fatal"], event: "FatalEvent") -> None: ...
+
+ @overload
+ def emit(self, event_name: Literal["error"], event: "ErrorEvent") -> None: ...
+
+ @overload
+ def emit(
+ self, event_name: Literal["voice_changed"], event: "VoiceChangedEvent"
+ ) -> None: ...
+
+ @overload
+ def emit(
+ self, event_name: Literal["state_changed"], event: "StateChangedEvent"
+ ) -> None: ...
+
+ @overload
+ def emit(
+ self,
+ event_name: Literal["generation_start"],
+ event: "GenerationStartEvent",
+ ) -> None: ...
+
+ @overload
+ def emit(
+ self,
+ event_name: Literal["generation_end"],
+ event: "GenerationEndEvent",
+ ) -> None: ...
+
+ @overload
+ def emit(
+ self,
+ event_name: Literal["generation_error"],
+ event: "GenerationErrorEvent",
+ ) -> None: ...
+
+ @overload
+ def emit(
+ self, event_name: Literal["audio_start"], event: "AudioStartEvent"
+ ) -> None: ...
+
+ @overload
+ def emit(
+ self, event_name: Literal["audio_end"], event: "AudioEndEvent"
+ ) -> None: ...
+
+ @overload
+ def emit(
+ self,
+ event_name: Literal["character_changed"],
+ event: "CharacterChangedEvent",
+ ) -> None: ...
+
+ @overload
+ def emit(
+ self,
+ event_name: Literal["character_loaded"],
+ event: "CharacterLoadedEvent",
+ ) -> None: ...
+
+ @overload
+ def emit(
+ self,
+ event_name: Literal["character_unloaded"],
+ event: "CharacterUnloadedEvent",
+ ) -> None: ...
+
+ @overload
+ def emit(self, event_name: EventName, event: EventPayload) -> None: ...
+
+ def emit(self, event_name: EventName, event: EventPayload) -> None:
+ """Dispatch an event to all current subscribers.
+
+ Args:
+ event_name: Event name being dispatched.
+ event: Event payload delivered to subscribers.
+ """
+ self._validate_event_name(event_name)
+ with self._lock:
+ callbacks = list(self._callbacks.get(event_name, ()))
+ owners = {
+ callback: self._owners.get(
+ (event_name, callback), self._describe_callback(callback)
+ )
+ for callback in callbacks
+ }
+
+ for callback in callbacks:
+ try:
+ callback(event)
+ except Exception as exc:
+ owner_name = owners.get(callback, self._describe_callback(callback))
+ self._log_warning(
+ (
+ f"[Core] Event callback failed for '{event_name}' in "
+ f"'{owner_name}': {format_error(exc, self._dev)}"
+ ),
+ "warning",
+ )
+
+ @staticmethod
+ def _validate_event_name(event_name: EventName) -> None:
+ """Reject unknown event names."""
+ if event_name not in _EVENT_NAME_SET:
+ raise ValueError(f"unknown event name: {event_name}")
+
+ @staticmethod
+ def _describe_callback(callback: _DispatcherCallback) -> str:
+ """Return a useful callback label for logs."""
+ qualname = getattr(callback, "__qualname__", None)
+ if isinstance(qualname, str) and qualname:
+ return qualname
+ name = getattr(callback, "__name__", None)
+ if isinstance(name, str) and name:
+ return name
+ return callback.__class__.__name__
+
+
+def _store_subscription_metadata(
+ callback: Callable[..., None],
+ event_name: EventName,
+ enabled: bool,
+) -> Callable[..., None]:
+ """Attach one declared event subscription to a callback."""
+ EventDispatcher._validate_event_name(event_name)
+ subscription = EventSubscription(event_name=event_name, enabled=enabled)
+ existing = getattr(callback, EVENT_HANDLER_METADATA_ATTR, None)
+ if isinstance(existing, tuple):
+ filtered = tuple(
+ item
+ for item in existing
+ if not isinstance(item, EventSubscription) or item.event_name != event_name
+ )
+ setattr(callback, EVENT_HANDLER_METADATA_ATTR, filtered + (subscription,))
+ return callback
+ setattr(callback, EVENT_HANDLER_METADATA_ATTR, (subscription,))
+ return callback
+
+
+def iter_subscriptions(
+ callback: Callable[..., None],
+) -> tuple[EventSubscription, ...]:
+ """Return the declared event subscriptions stored on a callback.
+
+ Args:
+ callback: Function or method that may carry subscription metadata.
+
+ Returns:
+ tuple[EventSubscription, ...]: Declared event subscriptions attached by ``@subscribe``.
+ """
+ subscriptions = getattr(callback, EVENT_HANDLER_METADATA_ATTR, ())
+ if not isinstance(subscriptions, tuple):
+ return ()
+ normalized: list[EventSubscription] = []
+ for item in subscriptions:
+ if isinstance(item, EventSubscription):
+ normalized.append(item)
+ elif isinstance(item, str):
+ normalized.append(
+ EventSubscription(event_name=cast(EventName, item), enabled=True)
+ )
+ return tuple(normalized)
+
+
+@overload
+def subscribe(
+ event_name: Literal["ready"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["shutdown"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["fatal"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["error"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["voice_changed"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["state_changed"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["generation_start"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["generation_end"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["generation_error"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["audio_start"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["audio_end"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["character_changed"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["character_loaded"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+@overload
+def subscribe(
+ event_name: Literal["character_unloaded"],
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]: ...
+
+
+def subscribe(
+ event_name: EventName,
+ *,
+ enabled: bool = True,
+) -> Callable[[_DecoratedCallback], _DecoratedCallback]:
+ """Declare an event subscription on a function or bound-method definition.
+
+ Args:
+ event_name: Event name that should be attached to the callback metadata.
+ enabled: Whether the handler should be auto-registered when discovered.
+
+ Returns:
+ Callable[..., Callable[..., None]]: Decorator that records the event name on the callback.
+ """
+
+ def decorator(callback: _DecoratedCallback) -> _DecoratedCallback:
+ return cast(
+ _DecoratedCallback,
+ _store_subscription_metadata(
+ cast(_DispatcherCallback, callback),
+ event_name,
+ enabled,
+ ),
+ )
+
+ return decorator
diff --git a/celune/extensions/manager.py b/celune/extensions/manager.py
index 7f0ef63..b6c5c09 100644
--- a/celune/extensions/manager.py
+++ b/celune/extensions/manager.py
@@ -3,22 +3,54 @@
import sys
import inspect
+import warnings
import threading
import traceback
import importlib.util
from pathlib import Path
-
-from ..utils import format_error
+from types import ModuleType
+from dataclasses import dataclass
+from typing import Callable, Optional, cast
+
+from ..typing.events import EventName
+from ..typing.events import EventPayload
+from ..utils import format_error, discard
+from ..dataclasses.events import ReadyEvent
+from ..i18n import string
from .base import CeluneContext, CeluneExtension
from ..exceptions import InvalidExtensionError, ExtensionAlreadyRegisteredError
+from .events import (
+ EventDispatcher,
+ RegisteredEventHandler,
+ iter_subscriptions,
+)
+
+
+@dataclass(frozen=True)
+class _ModuleRegistration:
+ """Track callbacks registered from one imported extension module."""
+
+ module_name: str
+ owner_key: str
class CeluneExtensionManager:
"""Celune's extension manager."""
- def __init__(self, context: CeluneContext) -> None:
+ def __init__(
+ self,
+ context: CeluneContext,
+ dispatcher: Optional[EventDispatcher] = None,
+ ) -> None:
self.context = context
+ self.dispatcher = dispatcher or EventDispatcher(
+ log_warning=context.log,
+ dev=context.dev,
+ )
self.extensions: dict[str, CeluneExtension] = {}
+ self._event_registrations: dict[str, list[RegisteredEventHandler]] = {}
+ self._module_registrations: dict[str, _ModuleRegistration] = {}
+ self._extension_modules: dict[str, str] = {}
self.auto_started = False
def register(self, extension_cls: type[CeluneExtension]) -> CeluneExtension:
@@ -51,29 +83,82 @@ def register(self, extension_cls: type[CeluneExtension]) -> CeluneExtension:
)
self.extensions[name] = instance
+ self._extension_modules[name] = extension_cls.__module__
+ self._register_extension_handlers(instance)
+ self._register_legacy_autostart_handler(instance)
self.context.log_dev(f"[Core] Registered extension: {name}")
return instance
+ def unregister(self, name: str) -> None:
+ """Unregister one extension and any event handlers it owns.
+
+ Args:
+ name: Registered extension name to remove.
+
+ Raises:
+ InvalidExtensionError: The requested extension is not currently registered.
+ """
+ extension = self.extensions.pop(name, None)
+ if extension is None:
+ raise InvalidExtensionError(f"extension '{name}' is not registered")
+
+ self._unregister_owner(name)
+ module_name = self._extension_modules.pop(name, "")
+ if module_name and module_name not in self._extension_modules.values():
+ module_registration = self._module_registrations.pop(module_name, None)
+ if module_registration is not None:
+ self._unregister_owner(module_registration.owner_key)
+
+ self.context.log_dev(f"[Core] Unregistered extension: {name}")
+
+ def unregister_all(self) -> None:
+ """Unregister all loaded extensions and auto-registered handlers."""
+ for name in list(self.extensions.keys()):
+ self.unregister(name)
+ for owner_key in list(self._event_registrations.keys()):
+ self._unregister_owner(owner_key)
+ self._module_registrations.clear()
+
+ def emit(self, event_name: EventName, event: EventPayload) -> None:
+ """Forward one event to the shared dispatcher.
+
+ Args:
+ event_name: Event name to emit.
+ event: Typed payload instance to deliver.
+ """
+ self.dispatcher.emit(event_name, event)
+
def autostart_all(self) -> None:
- """Autostart all available Celune extensions."""
+ """Run deprecated legacy autostart handlers."""
if self.auto_started:
self.context.log(
- "[Core] Cannot autostart Celune extensions more than one time.",
+ string("extensions.autostart_once"),
"warning",
)
return
+ warnings.warn(
+ string("extensions.autostart_all_deprecated"),
+ DeprecationWarning,
+ stacklevel=2,
+ )
started = 0
for name, ext in self.extensions.items():
- if ext.AUTOSTART:
- self.context.log_dev(f"[Core] Auto-starting: {name}")
+ if self._uses_legacy_autostart(ext):
+ self.context.log_dev(f"[Core] Running autostart for: {name}")
def runner(e=ext, n=name):
try:
e.autostart()
except Exception as ex:
self.context.log(
- f"[Core] Could not autostart {n}: {traceback.format_exc() if self.context.dev else ex}",
+ string(
+ "extensions.autostart_failed",
+ name=n,
+ error=(
+ traceback.format_exc() if self.context.dev else ex
+ ),
+ ),
"warning",
)
@@ -105,8 +190,11 @@ def runner() -> None:
ext.invoke(*args, **kwargs)
except Exception as ex:
self.context.log(
- f"[Core] Failed to invoke '{name}': "
- f"{format_error(ex, self.context.dev)}",
+ string(
+ "extensions.invoke_failed",
+ name=name,
+ error=format_error(ex, self.context.dev),
+ ),
"warning",
)
@@ -130,16 +218,17 @@ def autoload(self, folder: str = "extensions") -> None:
if not extensions_dir.exists():
self.context.log(
- f"[Core] Extension folder not found: {extensions_dir}", "warning"
+ string("extensions.folder_not_found", path=extensions_dir),
+ "warning",
)
- self.context.log("Extensions will not be available.", "warning")
+ self.context.log(string("extensions.unavailable"), "warning")
return
if not extensions_dir.is_dir():
self.context.log(
- f"[Core] Extension path is not a directory: {extensions_dir}"
+ string("extensions.path_not_directory", path=extensions_dir)
)
- self.context.log("Extensions will not be available.", "warning")
+ self.context.log(string("extensions.unavailable"), "warning")
return
self.context.log_dev(f"[Core] Scanning extension folder: {extensions_dir}")
@@ -154,7 +243,7 @@ def autoload(self, folder: str = "extensions") -> None:
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
self.context.log(
- f"[Core] Could not load spec for: {file_path.name}"
+ string("extensions.spec_load_failed", name=file_path.name)
)
continue
@@ -163,13 +252,21 @@ def autoload(self, folder: str = "extensions") -> None:
spec.loader.exec_module(module)
except Exception as e:
self.context.log(
- f"[Core] Failed to import '{file_path.name}': "
- f"{traceback.format_exc() if self.context.dev else e}",
+ string(
+ "extensions.import_failed",
+ name=file_path.name,
+ error=traceback.format_exc() if self.context.dev else e,
+ ),
"warning",
)
continue
found_any = False
+ module_handlers_registered = self._register_module_handlers(
+ module,
+ owner_name=file_path.stem,
+ )
+ found_any = module_handlers_registered
for _, obj in inspect.getmembers(module, inspect.isclass):
if not issubclass(obj, CeluneExtension):
@@ -186,13 +283,172 @@ def autoload(self, folder: str = "extensions") -> None:
found_any = True
except Exception as e:
self.context.log(
- f"[Core] Failed to register '{obj.__name__}' "
- f"from '{file_path.name}': {traceback.format_exc() if self.context.dev else e}",
+ string(
+ "extensions.register_failed",
+ name=obj.__name__,
+ file_name=file_path.name,
+ error=traceback.format_exc() if self.context.dev else e,
+ ),
"warning",
)
if not found_any:
self.context.log(
- f"[Core] {file_path.name} is not a Celune extension, skipping",
+ string(
+ "extensions.not_extension_skipping",
+ file_name=file_path.name,
+ ),
"warning",
)
+
+ def _register_extension_handlers(self, extension: CeluneExtension) -> None:
+ """Auto-register decorated event handlers declared on one extension class."""
+ handlers: list[RegisteredEventHandler] = []
+ for _, function in inspect.getmembers(type(extension), inspect.isfunction):
+ subscriptions = iter_subscriptions(function)
+ if not subscriptions:
+ continue
+
+ bound = getattr(extension, function.__name__)
+ if not callable(bound):
+ continue
+
+ callback = bound
+ for subscription in subscriptions:
+ if not subscription.enabled:
+ self.context.log_dev(
+ f"[Core] Disabled subscription skipped for extension: {extension.name}"
+ )
+ continue
+ registered = self._register_handler(
+ owner_name=extension.name,
+ event_name=subscription.event_name,
+ callback=cast(Callable[..., None], callback),
+ )
+ handlers.append(registered)
+
+ if handlers:
+ self._event_registrations[extension.name] = handlers
+
+ def _register_legacy_autostart_handler(self, extension: CeluneExtension) -> None:
+ """Bridge deprecated ``autostart()`` handlers onto the ``ready`` event."""
+ if not self._uses_legacy_autostart(extension):
+ return
+
+ warnings.warn(
+ string("extensions.autostart_deprecated"),
+ DeprecationWarning,
+ stacklevel=2,
+ )
+
+ def legacy_ready_callback(
+ event: ReadyEvent, ext: CeluneExtension = extension
+ ) -> None:
+ discard(event)
+
+ def runner() -> None:
+ try:
+ ext.autostart()
+ except Exception as ex:
+ self.context.log(
+ f"[Core] Could not autostart {ext.name}: "
+ f"{format_error(ex, self.context.dev)}",
+ "warning",
+ )
+
+ threading.Thread(target=runner, daemon=True).start()
+
+ handler = self._register_handler(
+ owner_name=extension.name,
+ event_name="ready",
+ callback=legacy_ready_callback,
+ )
+ self._event_registrations.setdefault(extension.name, []).append(handler)
+
+ @staticmethod
+ def _uses_legacy_autostart(extension: CeluneExtension) -> bool:
+ """Return whether one extension still relies on deprecated autostart hooks."""
+ extension_type = type(extension)
+ return extension_type.AUTOSTART or (
+ extension_type.autostart is not CeluneExtension.autostart
+ )
+
+ def _register_module_handlers(
+ self,
+ module: ModuleType,
+ owner_name: str,
+ ) -> bool:
+ """Auto-register decorated module-level handlers from one extension module."""
+ owner_key = f"module:{module.__name__}"
+ handlers: list[RegisteredEventHandler] = []
+
+ for _, function in inspect.getmembers(module, inspect.isfunction):
+ if function.__module__ != module.__name__:
+ continue
+
+ subscriptions = iter_subscriptions(function)
+ if not subscriptions:
+ continue
+
+ for subscription in subscriptions:
+ if not subscription.enabled:
+ self.context.log_dev(
+ f"[Core] Disabled module subscription skipped for: {owner_name}"
+ )
+ continue
+ handlers.append(
+ self._register_handler(
+ owner_name=owner_name,
+ event_name=subscription.event_name,
+ callback=function,
+ )
+ )
+
+ if not handlers:
+ return False
+
+ previous = self._module_registrations.get(module.__name__)
+ if previous is not None:
+ self._unregister_owner(previous.owner_key)
+
+ self._event_registrations[owner_key] = handlers
+ self._module_registrations[module.__name__] = _ModuleRegistration(
+ module_name=module.__name__,
+ owner_key=owner_key,
+ )
+ self.context.log_dev(
+ f"[Core] Registered {len(handlers)} event handler(s) from module: {owner_name}"
+ )
+ return True
+
+ def _register_handler(
+ self,
+ *,
+ owner_name: str,
+ event_name: EventName,
+ callback: Callable[..., None],
+ ) -> RegisteredEventHandler:
+ """Register one discovered callback against the dispatcher."""
+ if not callable(callback):
+ raise TypeError("event callback is not callable")
+
+ typed_callback = cast(Callable[[EventPayload], None], callback)
+ self.dispatcher.subscribe(
+ event_name,
+ typed_callback,
+ owner_name=owner_name,
+ )
+ return RegisteredEventHandler(
+ event_name=event_name,
+ callback=typed_callback,
+ owner_name=owner_name,
+ )
+
+ def _unregister_owner(self, owner_key: str) -> None:
+ """Remove all dispatcher registrations owned by one extension or module."""
+ registrations = self._event_registrations.pop(owner_key, ())
+ for registration in registrations:
+ self.dispatcher.unsubscribe(
+ registration.event_name,
+ registration.callback,
+ )
diff --git a/celune/i18n.py b/celune/i18n.py
index 4eecb69..84a5737 100644
--- a/celune/i18n.py
+++ b/celune/i18n.py
@@ -1,18 +1,75 @@
# SPDX-License-Identifier: MIT
-"""Celune internationalization stubs."""
+"""Celune internationalization helpers backed by JSON language files."""
import os
+import json
import ctypes
import contextlib
import locale as _locale # else it gets shadowed
+from pathlib import Path
from typing import Optional
from types import SimpleNamespace
DEFAULT_LOCALE = "en"
+_LANG_DIR = Path(__file__).resolve().parent / "lang"
-STRINGS: dict[str, dict[str, str]] = {
- "en": {},
-}
+STRINGS: dict[str, dict[str, str]] = {}
+
+
+def _normalize_locale_name(locale_name: Optional[str]) -> str:
+ """Normalize a locale label into a lookup-friendly language code."""
+ if not locale_name:
+ return DEFAULT_LOCALE
+
+ return locale_name.replace("_", "-").split(".", maxsplit=1)[0].lower()
+
+
+def _locale_candidates(locale_name: Optional[str]) -> list[str]:
+ """Return locale lookup candidates from most to least specific."""
+ normalized = _normalize_locale_name(locale_name)
+ candidates = [normalized]
+
+ if "-" in normalized:
+ candidates.append(normalized.split("-", maxsplit=1)[0])
+
+ if DEFAULT_LOCALE not in candidates:
+ candidates.append(DEFAULT_LOCALE)
+
+ return candidates
+
+
+def _lang_file(locale_name: str) -> Path:
+ """Return the JSON file path for one normalized language code."""
+ return _LANG_DIR / f"{locale_name}.json"
+
+
+def _load_locale_strings(locale_name: str) -> dict[str, str]:
+ """Load one locale file from disk, returning an empty table when absent."""
+ normalized = _normalize_locale_name(locale_name)
+ path = _lang_file(normalized)
+
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
+ return {}
+
+ if not isinstance(payload, dict):
+ return {}
+
+ resolved: dict[str, str] = {}
+ for key, value in payload.items():
+ if isinstance(key, str) and isinstance(value, str):
+ resolved[key] = value
+ return resolved
+
+
+def _ensure_locale_loaded(locale_name: Optional[str]) -> None:
+ """Load one locale table into the in-memory cache when needed."""
+ normalized = _normalize_locale_name(locale_name)
+ if normalized in STRINGS:
+ return
+
+ STRINGS[normalized] = _load_locale_strings(normalized)
def get_system_locale() -> str:
@@ -29,15 +86,16 @@ def get_system_locale() -> str:
with contextlib.suppress(Exception):
windll = getattr(ctypes, "windll", SimpleNamespace()).kernel32
lang_code = windll.GetUserDefaultUILanguage()
- return _locale.windows_locale.get(lang_code, "en")
+ return _locale.windows_locale.get(lang_code, DEFAULT_LOCALE)
lang = os.environ.get("LANG")
if lang:
return lang.split(".")[0]
- return "en"
+ return DEFAULT_LOCALE
+_ensure_locale_loaded(DEFAULT_LOCALE)
_current_locale = get_system_locale()
@@ -71,11 +129,15 @@ def string(key: str, locale: Optional[str] = None, **kwargs) -> str:
Returns:
str: The translated string, or the key itself when no translation exists.
"""
- lang = locale or _current_locale
- text = STRINGS.get(lang, {}).get(key)
+ text = None
+ for lang in _locale_candidates(locale or _current_locale):
+ _ensure_locale_loaded(lang)
+ text = STRINGS.get(lang, {}).get(key)
+ if text is not None:
+ break
if text is None:
- text = STRINGS.get(DEFAULT_LOCALE, {}).get(key, key)
+ text = key
if kwargs:
return text.format(**kwargs)
diff --git a/celune/lang/en.json b/celune/lang/en.json
new file mode 100644
index 0000000..0f98074
--- /dev/null
+++ b/celune/lang/en.json
@@ -0,0 +1,449 @@
+{
+ "api.cannot_convert": "I can't convert that.",
+ "api.cannot_play_now": "I can't play that right now.",
+ "api.could_not_convert": "I couldn't convert that.",
+ "api.could_not_start": "Could not start the API: {error}",
+ "api.invalid_input": "I don't understand your input.",
+ "api.invalid_voice": "I don't know how to speak in that voice.",
+ "api.rate_limit": "Please wait until you make me speak again.",
+ "api.runner_exit_code": "API runner has exited. Exit code {code}",
+ "api.runner_started": "{app_name} API has started on http://{host}:{port}",
+ "api.runner_timeout": "API runner has not responded after {seconds:.1f}s, and has timed out.",
+ "api.sound_too_large": "That sound is too large for me to play.",
+ "api.source_audio_too_large": "That source audio is too large for me to convert.",
+ "api.speech_job_unknown": "I don't know that speech job.",
+ "api.unauthorized": "Who are you? Send me an authentication token.",
+ "celune.all_voices_available": "All voices are available.",
+ "celune.api_port_invalid": "{app_name} API port ({port}) is invalid, will use 2060 instead.",
+ "celune.api_port_out_of_range": "{app_name} API port ({port}) is out of range, will use 2060 instead.",
+ "celune.api_rate_limit_invalid": "{app_name} API rate limit ({rate}) is invalid, using 60/min.",
+ "celune.api_unavailable": "{app_name} API will not be available.",
+ "celune.app_busy": "{app_name} is currently busy",
+ "celune.app_sleeping": "{app_name} is currently sleeping",
+ "celune.audio_conversion_unavailable": "Audio conversion is unavailable outside voice conversion mode.",
+ "celune.compatible_bundle_required": "Backend '{backend}' requires a compatible CEVOICE/CECHAR package with at least one valid voice identifier.",
+ "celune.backend_restore_failed": "Could not load this backend. The previous backend was restored.",
+ "celune.backend_switch_timeout": "Timed out while switching backends.",
+ "celune.busy_thinking": "Tried to think while {app_name} was busy.",
+ "celune.cannot_think_sleeping": "Cannot think while {app_name} is sleeping.",
+ "celune.character_restore_failed": "Could not switch to this character. The previous character was restored.",
+ "celune.character_switch_timeout": "Timed out while switching characters.",
+ "celune.current_character": "Current character: {character}",
+ "celune.current_character_default": "Current character: {character} (default)",
+ "celune.current_vram_preset": "Current VRAM preset: {preset}",
+ "celune.default_model_failed_short": "Default model failed to load",
+ "celune.default_model_load_failed": "{app_name} could not load the default model.",
+ "celune.deleting": "Deleting...",
+ "celune.initializing_persona": "Initializing Persona...",
+ "celune.input_too_long_to_normalize": "Input is too long to normalize.",
+ "celune.invalid_backend_type": "'backend_name' must be a backend instance, backend type, or backend name string",
+ "celune.internal_error": "An internal error occurred: {error}",
+ "celune.loading_normalizer": "Loading normalizer {model_id} on {device}...",
+ "celune.model_unloaded_while_waiting": "Model was unloaded while waiting to become ready.",
+ "celune.no_api_token": "No API token set. {app_name} API will bind only to the local network.",
+ "celune.no_valid_vc_backend": "No valid voice conversion backend",
+ "celune.no_vc_backend": "No voice conversion backend is available.",
+ "celune.no_voices_loaded": "No voices were loaded.",
+ "celune.no_voices_loaded_short": "No voices loaded",
+ "celune.normalization_error": "[NORMALIZATION ERROR] {error}",
+ "celune.normalization_took": "Normalization took {seconds} seconds.",
+ "celune.normalization_unavailable": "Normalization will not be available.",
+ "celune.normalized_text": "Normalized text: {text}",
+ "celune.normalizer_bad_output": "Normalizer did not produce normal output.",
+ "celune.normalizer_error": "[NORMALIZER ERROR] {error}",
+ "celune.normalizer_failed": "Normalizer failed to load.",
+ "celune.normalizer_loaded": "Normalizer loaded.",
+ "celune.normalizer_returned_no_tokens": "Normalizer returned no tokens.",
+ "celune.not_possible": "Not possible",
+ "celune.package_import_failed": "Package import failed: {error}",
+ "celune.persona_init_failed": "Persona could not be initialized.",
+ "celune.persona_initialized": "Persona initialized.",
+ "celune.persona_not_initialized": "Persona not initialized.",
+ "celune.personas_unavailable": "Personas are unavailable. {app_name} is operating in speech-only mode.",
+ "celune.playback_idle_timeout": "Timed out while waiting for playback pipeline to become idle.",
+ "celune.port_unavailable": "Port {port} is unavailable.",
+ "celune.ready_for_vc": "Ready to accept voice conversions.",
+ "celune.ready_wait_not_fatal": "This is not a fatal error, the utterance may be retried.",
+ "celune.ready_wait_reason": "A possible reason for this may be a model download or high GPU activity.",
+ "celune.ready_wait_timeout": "Timed out while waiting to become ready.",
+ "celune.reload_already_in_progress": "A backend or character reload is already in progress.",
+ "celune.reload_error": "[RELOAD ERROR] {error}",
+ "celune.reloading": "{app_name} is reloading, please stand by...",
+ "celune.reloading_character": "{app_name} is reloading the character, please stand by...",
+ "celune.required_package_missing": "A required package ({package}) isn't installed.",
+ "celune.residual_temp_item": "{app_name} found a residual temporary item.",
+ "celune.residual_temp_items": "{app_name} found {count} residual temporary items.",
+ "celune.rewarming_up": "Rewarming up...",
+ "celune.say_instead": "Will say the input instead.",
+ "celune.speech_input_disabled_tutorial": "Speech input is disabled during the tutorial.",
+ "celune.speech_only_mode": "Continuing in speech-only mode.",
+ "celune.switched_backend": "Switched backend to {backend}.",
+ "celune.switched_character": "Switched to character: {character}",
+ "celune.switching_backend": "{app_name} is switching to {backend}, please wait...",
+ "celune.test_mode_active": "{app_name} is running in UI test mode. No actions will occur while in this mode.",
+ "celune.testing_environment": "Environment test...",
+ "celune.text_input_unavailable_vc": "Text input is unavailable in voice conversion mode.",
+ "celune.tokens_to_normalize": "Tokens to normalize: {count}",
+ "celune.unknown_backend": "Unknown backend: {backend} (available: {available})",
+ "celune.unknown_voice": "Unknown voice: {voice}",
+ "celune.voice_loaded": "Voice {voice} loaded.",
+ "celune.voice_pack_not_found": "Voice pack not found: {bundle}",
+ "celune.voice_switch_timeout": "Timed out while switching voice.",
+ "celune.waiting_for_models": "Waiting for models to load...",
+ "celune.wake_error": "[WAKE ERROR] {error}",
+ "celune.warmup_error": "[WARMUP ERROR] {error}",
+ "celune.warmup_failed": "[WARMUP] Warmup failed.",
+ "celune.warmup_failed_app": "{app_name} could not warm up",
+ "celune.warmup_start": "[WARMUP] Warming up...",
+ "cli.additional_debugging": "additional debugging:",
+ "cli.already_running": "{app_name} is already running.",
+ "cli.apply_launcher_update_failed": "{app_name} could not apply the launcher update: {error}",
+ "cli.apply_update_noninteractive": "Attempting to apply update non-interactively...",
+ "cli.apply_update_usage": "Usage: celune __apply_update [args...]",
+ "cli.cannot_start_normal_mode": "{app_name} cannot start in normal mode.",
+ "cli.celine_day_1": "I sense the presence of... her.",
+ "cli.celine_day_2": "I would rather not.",
+ "cli.config_could_not_be_read": "{app_name} configuration could not be read.",
+ "cli.config_created": "{app_name} configuration has been created.",
+ "cli.config_description": "View or edit {app_name}'s configuration.",
+ "cli.config_not_created": "{app_name} configuration has not been created yet.",
+ "cli.config_updated_defaults": "{app_name} configuration has been updated with new defaults.",
+ "config.audio_device_kind_input": "input",
+ "config.audio_device_kind_output": "output",
+ "config.audio_device_multiple_matches": "The specified {device_kind} device name has multiple matches for '{device_name}':\n{matches}\n\nPlease specify one of the above devices, then restart {app_name}.",
+ "cli.config_usage": "Usage: {program} config [view/edit]",
+ "cli.continuing_current_version": "Continuing with the current version.",
+ "cli.could_not_initialize": "{app_name} could not initialize.",
+ "cli.current_config": "Current {app_name} configuration:",
+ "cli.dependency_missing": "You do not have '{package_name}' installed.",
+ "cli.dependency_required": "{app_name} requires this library to function.",
+ "cli.doctor_attempting_fix": "Attempting to fix fixable problems...",
+ "cli.doctor_checking": "Checking your environment for compatibility with {app_name}...",
+ "cli.doctor_description": "Inspect {app_name}'s runtime environment without starting the app.",
+ "cli.doctor_fix_help": "Use --fix to run setup.py from the repository root.",
+ "cli.doctor_fix_limits": "Please note that this will not fix a Python version incompatibility or lack of a CUDA runtime.",
+ "cli.doctor_hint": "hint: {hint}",
+ "cli.doctor_performance_impacted": "{app_name}'s performance may be impacted.",
+ "cli.doctor_ready": "Your system is ready to run {app_name}.",
+ "cli.doctor_rerun_fix": "Rerun with --fix to attempt to fix some of these problems.",
+ "cli.doctor_summary": "Summary: {passes} passed, {warnings_count} warning{warning_suffix}, {failures} failed",
+ "cli.doctor_usage": "Usage: {program} doctor [--fix]",
+ "cli.doctor_will_not_work": "{app_name} will not work.",
+ "cli.fix_failed": "Failed to fix fixable problems: {error}",
+ "cli.full_traceback": "for full traceback:",
+ "cli.full_traceback_title": "For full traceback:",
+ "cli.headless_extensions_only": "While in this mode, input is only possible via {app_name} extensions.",
+ "cli.headless_press_ctrl_c": "Press CTRL+C in this window to stop it.",
+ "cli.help_available_commands": "Available commands:",
+ "cli.help_config": "config [view/edit]\t\tView or edit {app_name}'s configuration.",
+ "cli.help_default_start": "Providing no arguments implicitly defaults to starting {app_name}.",
+ "cli.help_description": "Display this help message.",
+ "cli.help_doctor": "doctor [--fix]\t\t\tInspect the environment without starting {app_name}.",
+ "cli.help_help": "help\t\t\t\tDisplay this help message.",
+ "cli.help_main_usage": "Usage: {program} [command]",
+ "cli.help_parameter_note": "Some commands may be used with a parameter (e.g. {program} --argument),",
+ "cli.help_start": "start/run [-v/-t]\t\t\tStart {app_name}.",
+ "cli.help_subcommand_note": "or with a subcommand (e.g. {program} argument).",
+ "cli.help_usage": "Usage: {program} help",
+ "cli.help_version": "version\t\t\t\tDisplay running {app_name} version.",
+ "cli.hint": "Hint:",
+ "cli.ide_terminals_differ": "Some IDE terminals may behave differently from a normal terminal.",
+ "cli.install_manually": "or install the package manually:",
+ "cli.internal_error_running": "An internal error occurred while {app_name} was running.",
+ "cli.invalid_argument": "Invalid argument.",
+ "cli.invalid_launcher_pid": "Invalid launcher PID.",
+ "cli.launcher_apply_artifact": "{app_name} will close so the launcher can apply the latest artifact.",
+ "cli.modified_version_note": "Note: This is a modified version of {app_name}.",
+ "cli.no_ansi": "This terminal does not support ANSI.",
+ "cli.no_argument_given": "No argument given.",
+ "cli.no_error_description": "no error description",
+ "cli.not_via_launcher": "{app_name} is not being launched via the {app_name} launcher.",
+ "cli.or_set_env_var": "or set the following environment variable:",
+ "cli.request_refresh_binaries": "Requesting the launcher to refresh the packaged binaries...",
+ "cli.run_help_hint": "Run `{program} help` to list available commands.",
+ "cli.run_once_to_create_config": "Run {app_name} at least once to create it.",
+ "cli.running_from_ide": "{app_name} is running from {ide}.",
+ "cli.running_headless": "{app_name} is running in headless mode.",
+ "cli.set_dev_true": "Set 'dev: true' in config.yaml",
+ "cli.setup_automatically": "Set up {app_name} automatically by running:",
+ "cli.setup_cmd_pip_install": " pip install {package_name}",
+ "cli.setup_cmd_setup_py": " python setup.py",
+ "cli.setup_cmd_uv_sync": " uv sync",
+ "cli.setup_with_uv": "or alternatively with uv:",
+ "cli.start_description": "Start {app_name}.",
+ "cli.start_usage": "Usage: {program} {command}",
+ "cli.suppress_message_run_with": "To suppress this message, run {app_name} with:",
+ "cli.too_many_arguments": "Too many arguments.",
+ "cli.traceback_cmd_dev_python": " CELUNE_DEV=1 python {script_name}",
+ "cli.traceback_cmd_python": " python {script_name}",
+ "cli.traceback_cmd_set_dev": "set CELUNE_DEV=1",
+ "cli.try_again_tomorrow": "Try again tomorrow.",
+ "cli.try_another_terminal": "Try using another terminal application.",
+ "cli.unknown_command_or_argument": "Unknown command or argument.",
+ "cli.update_choice_no": "No, continue as is",
+ "cli.update_choice_yes": "Yes, update now",
+ "cli.update_failed": "{app_name} could not update: {detail}",
+ "cli.update_found": "New update found.",
+ "cli.update_info_incomplete": "update information is incomplete",
+ "cli.update_prompt": "Do you want to update?",
+ "cli.update_success_restart": "{app_name} updated successfully. Restart {app_name} to apply changes.",
+ "cli.update_version_summary": "You are running {app_name} {local_version} ({local_revision}), latest version is {latest_label} ({latest_revision}).",
+ "cli.updating": "Updating {app_name}...",
+ "cli.version_description": "Display running {app_name} version.",
+ "cli.version_usage": "Usage: {program} version",
+ "commands.attachment_not_found": "Attachment not found: {path}",
+ "commands.attachments_added": "Attached {names}. {count} attachments will be sent in the next pass.",
+ "commands.attachments_cleared": "Attachments cleared.",
+ "commands.attachments_speech_only_mode": "Cannot add attachments while {app_name} is running in speech-only mode.",
+ "commands.backend_already_loaded": "This backend is already loaded.",
+ "commands.backend_not_switched": "Backend was not switched.",
+ "commands.backend_switch_failed": "Failed to switch backend: {error}",
+ "commands.backend_switched": "Switched to backend: {backend_name}",
+ "commands.cannot_play_audio": "Cannot play this audio: {error}",
+ "commands.cannot_play_file": "Cannot play this file: {error}",
+ "commands.character_already_loaded": "This character is already loaded.",
+ "commands.character_changed": "Character changed: {bundle}",
+ "commands.character_not_switched": "Could not switch character to {bundle}.",
+ "commands.character_switch_failed": "Cannot switch to this character: {error}",
+ "commands.consuming_live_input": "Now consuming from live input",
+ "commands.custom_seed_removed": "Custom seed removed.",
+ "commands.extension_error": "[EXT ERROR] {error}",
+ "commands.extension_not_found": "Extension not found: {name}",
+ "commands.extension_system_not_initialized": "Extension system not initialized.",
+ "commands.extensions_list": "Extensions: {names}",
+ "commands.help_arguments": "Arguments marked in <> are required, those marked in [] are optional.",
+ "commands.help_attach": "/attach [file...] - Attach one or more images or videos to the next persona reply.",
+ "commands.help_available": "Available commands:",
+ "commands.help_backend": "/backend - Hot-reload a TTS backend.",
+ "commands.help_cevoice": "/cevoice - Hot-reload a CEVOICE pack.",
+ "commands.help_consumebuffer": "/consumebuffer - Make {app_name} consume text from the live buffer without pressing CTRL+ENTER.",
+ "commands.help_consumebuffer_caution": "Caution: This feature may interfere with typing '...'.",
+ "commands.help_exit": "/exit - Exit {app_name}.",
+ "commands.help_extensions": "/extensions - List currently available {app_name} extensions.",
+ "commands.help_header": "--- {app_name} help topics ---",
+ "commands.help_help": "/help - Display this help message.",
+ "commands.help_invoke": "/invoke [args] - Invoke a {app_name} extension by its name.",
+ "commands.help_play": "/play - Play a sound effect by path.",
+ "commands.help_reverb": "/reverb - Change reverb strength.",
+ "commands.help_say": "/say - Speak text directly and bypass Persona for this one message.",
+ "commands.help_seed": "/seed [seed|random] - Set or clear the seed for speech outputs, affecting pronunciation and/or prosody.",
+ "commands.help_speed": "/speed - Change speaking speed.",
+ "commands.help_stop": "/stop - Terminate ongoing speech.",
+ "commands.help_tutorial": "/tutorial - Run {app_name}'s tutorial.",
+ "commands.help_vc": "/vc - Convert one local audio file through voice conversion and play it back.",
+ "commands.help_vcmode": "/vcmode - Switch SeedVC between talk and sing f0 conditioning.",
+ "commands.help_vcpitch": "/vcpitch - Adjust SeedVC pitch shift from -12 to +12 semitones.",
+ "commands.help_voiceprompt": "/voiceprompt - Change {app_name}'s voice prompt. This will allow you to steer her voice.",
+ "commands.help_voiceprompt_caution": "Caution: Some prompts may cause adverse effects. Choose prompts that enhance personality, rather than replace it.",
+ "commands.help_xvectoronly": "/xvectoronly - Toggle Qwen3 identity-only cloning.",
+ "commands.invalid_argument": "Invalid argument: {value}",
+ "commands.invalid_true_false_argument": "Invalid argument for '{command}', must be true/false.",
+ "commands.invalid_volume": "Invalid volume for '{command}', must be numeric.",
+ "commands.no_extensions_loaded": "No extensions loaded.",
+ "commands.no_tutorial_assets": "No tutorial assets found.",
+ "commands.not_consuming_live_input": "No longer consuming from live input",
+ "commands.nothing_to_stop": "Nothing to stop.",
+ "commands.playing_audio": "Playing {path} at {volume}% volume",
+ "commands.playing_youtube_audio": "Playing YouTube audio at {volume}% volume",
+ "commands.qwen3_identity_only_cloning": "Qwen3 identity-only cloning {state}.",
+ "commands.reverb_set": "Reverb strength set to {value}%.",
+ "commands.rubber_band_unavailable": "{app_name} cannot currently use Rubber Band.",
+ "commands.say_redundant": "This command is redundant in the current operation mode.",
+ "commands.say_submit_normally": "Submit inputs normally instead.",
+ "commands.seed_range": "Seed must be between 0 and {max_value}.",
+ "commands.seed_set": "Seed set to {value}.",
+ "commands.speed_set": "Speaking speed set to {value}%.",
+ "commands.state_disabled": "disabled",
+ "commands.state_enabled": "enabled",
+ "commands.tutorial_activated": "Tutorial activated. Listen to what's said to learn how to use {app_name}.",
+ "commands.tutorial_failed": "Tutorial failed: {error}",
+ "commands.tutorial_playback_failed": "Tutorial playback failed: {error}",
+ "commands.unknown_command": "Unknown command: {command}. Run /help for a list of commands.",
+ "commands.unmatched_ipa": "Found {count} unmatched IPA characters, output may be inaccurate.",
+ "commands.unsupported_attachment_type": "Unsupported attachment type: {path}",
+ "commands.usage_attach": "Usage: /attach [file...]",
+ "commands.usage_backend": "Usage: /backend ",
+ "commands.usage_cevoice": "Usage: /cevoice ",
+ "commands.usage_consumebuffer": "Usage: /consumebuffer ",
+ "commands.usage_invoke": "Usage: /invoke [args]",
+ "commands.usage_play": "Usage: /play [volume]",
+ "commands.usage_reverb": "Usage: /reverb ",
+ "commands.usage_say": "Usage: /say ",
+ "commands.usage_speed": "Usage: /speed ",
+ "commands.usage_vc": "Usage: /vc ",
+ "commands.usage_vcmode": "Usage: /vcmode ",
+ "commands.usage_vcpitch": "Usage: /vcpitch ",
+ "commands.usage_voiceprompt": "Usage: /voiceprompt ",
+ "commands.usage_xvectoronly": "Usage: /xvectoronly ",
+ "commands.value_out_of_range_reverb": "Value out of range. Expected 0-100%.",
+ "commands.value_out_of_range_speed": "Value out of range. Expected 80-120%.",
+ "commands.vc_decode_failed": "Cannot read this voice conversion input: {error}",
+ "commands.vc_file_not_found": "Voice conversion input not found: {path}",
+ "commands.vc_submission_failed": "Could not start voice conversion for {path}.",
+ "commands.vc_submitted": "Running voice conversion on {path}",
+ "commands.vcmode_set": "Voice conversion mode set to {mode}.",
+ "commands.vcpitch_range": "Pitch shift must be between -12 and 12 semitones.",
+ "commands.vcpitch_set": "Voice conversion pitch shift set to {value} semitones.",
+ "commands.voice_conversion_only": "This command is only available in voice conversion mode.",
+ "commands.voice_prompt_cleared": "Voice prompt cleared.",
+ "commands.voice_prompt_set": "Voice prompt set to '{prompt}'.",
+ "commands.voice_prompts_unavailable": "Voice prompts are unavailable with the currently loaded model.",
+ "commands.xvectoronly_qwen3_only": "This setting is only available on the Qwen3 backend.",
+ "extensions.autostart_all_deprecated": "CeluneExtensionManager.autostart_all() is deprecated, please use @celune.subscribe('ready') in your extensions instead",
+ "extensions.autostart_deprecated": "CeluneExtension.autostart() is deprecated, please use @celune.subscribe('ready') instead",
+ "extensions.autostart_failed": "[Core] Could not autostart {name}: {error}",
+ "extensions.autostart_once": "[Core] Cannot autostart Celune extensions more than one time.",
+ "extensions.folder_not_found": "[Core] Extension folder not found: {path}",
+ "extensions.import_failed": "[Core] Failed to import '{name}': {error}",
+ "extensions.invoke_failed": "[Core] Failed to invoke '{name}': {error}",
+ "extensions.not_extension_skipping": "[Core] {file_name} is not a Celune extension, skipping",
+ "extensions.path_not_directory": "[Core] Extension path is not a directory: {path}",
+ "extensions.register_failed": "[Core] Failed to register '{name}' from '{file_name}': {error}",
+ "extensions.spec_load_failed": "[Core] Could not load spec for: {name}",
+ "extensions.unavailable": "Extensions will not be available.",
+ "headless.error_prefix": "[ERROR] ",
+ "headless.no_attached_instance": "{class_name} has no attached {app_name} instance: this will do nothing",
+ "headless.warn_prefix": "[WARN] ",
+ "osc.crash_1": "{app_name} has crashed.",
+ "osc.crash_2": "Please check {app_name}'s",
+ "osc.crash_3": "log widget for",
+ "osc.crash_4": "more information.",
+ "osc.exiting": "{app_name} is exiting...",
+ "osc.starting": "{app_name} is starting up...",
+ "pipeline.april_fools": "We are about to do a funny!",
+ "pipeline.audio_stream_init_failed": "{app_name} could not initialize the audio stream.",
+ "pipeline.busy_action": "Tried to {action} while {app_name} was busy.",
+ "pipeline.cannot_find_sound": "{app_name} cannot find {path}.",
+ "pipeline.cannot_speak_sleeping": "Cannot speak while {app_name} is sleeping.",
+ "pipeline.close_memory_apps": "Please close any memory-resident applications to improve performance.",
+ "pipeline.could_not_generate": "{app_name} could not generate the input",
+ "pipeline.download_failed": "Could not download audio.",
+ "pipeline.download_timeout": "Timed out downloading audio.",
+ "pipeline.download_youtube_failed_short": "Could not download YouTube audio",
+ "pipeline.downloader_no_file": "Downloader returned no file.",
+ "pipeline.exiting": "Exiting...",
+ "pipeline.flac_save_failed": "Could not save FLAC output: {error}",
+ "pipeline.forcefully_stopping_speech": "Forcefully stopping speech.",
+ "pipeline.gen_error": "[GEN ERROR] {error}",
+ "pipeline.generation_done": "[GEN] done",
+ "pipeline.generation_speed": "Speed: x{speed}",
+ "pipeline.generation_summary": "[GEN] {speech_seconds} seconds, took {generation_seconds} seconds",
+ "pipeline.just_type": "Just type. {choice}",
+ "pipeline.may_be_silent": "This utterance may be unexpectedly silent.",
+ "pipeline.may_not_say_properly": "{app_name} may not say the input properly.",
+ "pipeline.no_audio_device": "No suitable audio device is available.",
+ "pipeline.no_audio_devices_short": "No suitable audio devices",
+ "pipeline.not_ready_app": "{app_name} is not currently ready",
+ "pipeline.nothing_to_say": "Nothing to say",
+ "pipeline.outputs_create_failed": "Cannot create outputs directory, not saving FLAC output: {error}",
+ "pipeline.outputs_path_creating": "Outputs path not found, creating...",
+ "pipeline.persona_empty_response": "Persona system returned an empty response.",
+ "pipeline.persona_not_connected": "Persona system is not connected.",
+ "pipeline.persona_request_failed": "Persona system request failed: {error}",
+ "pipeline.playback_error": "Playback error",
+ "pipeline.playing_label": "Playing {label}",
+ "pipeline.ready_to_speak": "Ready to speak.",
+ "pipeline.ready_to_vc": "Ready to convert audio.",
+ "pipeline.received_unsupported_language": "Received unsupported input in the following language: {language}",
+ "pipeline.rubber_band_unavailable": "Rubber Band is unavailable, speed controls disabled.",
+ "pipeline.sample_rate_length": "Sample rate: {sample_rate} Hz, length: {seconds} seconds",
+ "pipeline.sfx_format_unsupported": "{app_name} does not support SFX in this format.",
+ "pipeline.silent_regenerating": "Previous utterance was silent, regenerating...",
+ "pipeline.speak_waiting_reload": "Speak request is waiting for model reload to finish.",
+ "pipeline.supported_formats": "Supported formats: {formats}",
+ "pipeline.token_limit_reached": "Generation reached the token limit before completion. Output may be truncated or sound incorrect.",
+ "pipeline.ttfc_ms": "TTFC: {milliseconds} ms",
+ "pipeline.ttfp_seconds": "TTFP: {seconds} seconds",
+ "pipeline.vc_backend_unconfigured": "Voice conversion backend is not configured.",
+ "pipeline.vc_reference_load_failed": "Could not load reference audio for voice conversion: {error}",
+ "pipeline.vram_low": "{app_name} is running out of VRAM. Check the bottom right of {app_name}'s window to learn more.",
+ "pipeline.youtube_download_start": "[SFX] Downloading audio from {url}...",
+ "pipeline.yt_dlp_missing": "yt-dlp is not installed, cannot play YouTube audio.",
+ "pipeline.yt_dlp_required": "yt-dlp is required for YouTube playback",
+ "status.api_starting": "Starting up",
+ "status.could_not_continue": "{app_name} could not continue",
+ "status.could_not_reload": "{app_name} could not reload",
+ "status.could_not_start": "{app_name} could not start",
+ "status.could_not_wake": "{app_name} could not wake",
+ "status.downloading_audio": "Downloading audio",
+ "status.generating": "Generating",
+ "status.idle": "Idle",
+ "status.initializing": "Initializing",
+ "status.normalizing": "Normalizing",
+ "status.reloading": "Reloading",
+ "status.reloading_backend": "Reloading backend",
+ "status.reloading_character": "Reloading character",
+ "status.restoring_backend": "Restoring backend",
+ "status.sleeping": "Sleeping",
+ "status.speaking": "Speaking",
+ "status.thinking": "Thinking",
+ "status.waiting_for_model": "Waiting for model",
+ "status.waking_up": "Waking up",
+ "status.warming_up": "Warming up",
+ "seedvc.loading_models": "Loading Seed-VC models...",
+ "seedvc.package_required": "Seed-VC backend requires the '{package}' package to be installed.",
+ "seedvc.target_reference_required": "Seed-VC requires at least one target reference WAV for conversion.",
+ "ui.app_could_not_start": "{app_name} could not start",
+ "ui.audio_input_label": "audio input",
+ "ui.command_parsing_error": "Command parsing error: {error}",
+ "ui.core_engine_not_loaded": "Core engine is not loaded.",
+ "ui.error_signal_unavailable": "Could not play the error signal.",
+ "ui.idle_status": "Idle",
+ "ui.init_error": "[INIT ERROR] {error}",
+ "ui.input_placeholder": "Enter text to speak here",
+ "ui.invalid_theme_defaulting_dark": "Invalid theme, defaulting to dark",
+ "ui.no_voice_set": "No Voice Set",
+ "ui.no_voices_loaded": "No voices are loaded.",
+ "ui.recording_empty": "No audio was captured.",
+ "ui.recording_open_input_failed": "Could not open the audio input device: {error}",
+ "ui.recording_start_failed": "Could not start recording from {label}: {error}",
+ "ui.recording_started": "Recording from {label}. Press CTRL+R again to stop.",
+ "ui.recording_stopped": "Stopped recording from {label}.",
+ "ui.recording_stopped_feedback": "Stopped recording from {label}. A sudden RMS spike suggested microphone feedback.",
+ "ui.recording_stream_chunk_failed": "Could not stream microphone audio from {label}: {error}",
+ "ui.recording_stream_submit_failed": "Could not stream the microphone conversion right now.",
+ "ui.footer_toggle_recording": "CTRL+R toggle recording",
+ "ui.say_placeholder": "Say something...",
+ "ui.sleeping_log": "{app_name} is currently sleeping. Type anything to wake up.",
+ "ui.sleeping_status": "Sleeping",
+ "ui.test_mode_active": "UI test mode active",
+ "ui.tutorial_placeholder": "Currently in tutorial mode",
+ "ui.tutorial_prompt": "New to {app_name}? Type /tutorial to begin the tutorial.",
+ "ui.vc_mode_changed": "Voice conversion mode set to {mode}.",
+ "ui.vc_mode_sing": "Sing",
+ "ui.vc_mode_talk": "Talk",
+ "ui.vc_pitch_button": "Pitch {value}",
+ "ui.vc_pitch_changed": "Voice conversion pitch shift set to {value} semitones.",
+ "ui.voice_changer_placeholder": "Currently in voice changer mode",
+ "ui.wait_placeholder": "Please wait",
+ "webui.busy_try_again": "I'm currently busy. Try again later.",
+ "webui.cannot_change_voice_right_now": "I can't change my voice right now.",
+ "webui.cannot_convert_right_now": "I can't convert that right now.",
+ "webui.command_parsing_error": "Command parsing error: {error}",
+ "webui.conversion_mode_info": "Use Talk for speech and Sing to preserve melodic pitch.",
+ "webui.conversion_mode_label": "Conversion Mode",
+ "webui.conversion_only_in_vc_mode": "Audio conversion is only available in voice conversion mode.",
+ "webui.convert_button": "Convert Audio",
+ "webui.converted_audio_label": "Converted Audio",
+ "webui.default_voice_button": "Balanced",
+ "webui.error": "[WEBUI ERROR] {error}",
+ "webui.features_may_differ": "Usage may differ. Some {app_name} features may not be available.",
+ "webui.input_placeholder": "Enter text to speak here",
+ "webui.must_be_running_for_commands": "{app_name} must be running to run commands.",
+ "webui.not_available": "I'm not currently available.",
+ "webui.not_returned_from_sleep": "{app_name} has not yet returned from sleep mode.",
+ "webui.pitch_shift_info": "Raise or lower the source by semitones before voice conversion.",
+ "webui.pitch_shift_label": "Pitch Shift",
+ "webui.send_button": "Send",
+ "webui.sleeping_log": "{app_name} is currently sleeping. Type anything to wake up.",
+ "webui.source_audio_label": "Source Audio",
+ "webui.tts_tab_label": "TTS",
+ "webui.tutorial_placeholder": "Currently in tutorial mode",
+ "webui.upload_audio_first": "Upload or record audio before starting voice conversion.",
+ "webui.vc_tab_label": "VC",
+ "webui.voice_changed": "Voice changed to {voice}.",
+ "webui.voice_changer_placeholder": "Currently in voice changer mode",
+ "webui.voice_ready": "Voice ready: {voice}.",
+ "webui.wait_placeholder": "Please wait",
+ "webui.wrong_mode": "I am not currently able to do this."
+}
diff --git a/celune/modeling.py b/celune/modeling.py
index e0be96f..1239774 100644
--- a/celune/modeling.py
+++ b/celune/modeling.py
@@ -9,7 +9,7 @@
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
-from .backends import CeluneBackend
+from .backends.tts import CeluneBackend
from .vram import resolve_vram_preset
from .constants import JSONSerializable, NORMALIZER_MODEL_ID
diff --git a/celune/paths.py b/celune/paths.py
index 881f130..b8fda79 100644
--- a/celune/paths.py
+++ b/celune/paths.py
@@ -1,16 +1,22 @@
# SPDX-License-Identifier: MIT
-"""Runtime filesystem paths for Celune user data."""
+"""Runtime filesystem paths and global Hugging Face runtime setup for Celune."""
+import os
import sys
import shutil
from pathlib import Path
from typing import Optional
from platformdirs import user_data_dir
+from huggingface_hub.utils import disable_progress_bars
+from transformers.utils.logging import disable_progress_bar
-from .constants import APP_SLUG
+from .constants import APP_NAME, APP_SLUG
_REPO_MARKERS = ("celune", "default_config.yaml", "pyproject.toml")
+_HF_HOME_ENV = "HF_HOME"
+_HF_HUB_CACHE_ENV = "HF_HUB_CACHE"
+_HF_HUB_DISABLE_PROGRESS_BARS_ENV = "HF_HUB_DISABLE_PROGRESS_BARS"
def running_compiled() -> bool:
@@ -48,12 +54,74 @@ def app_data_dir(create: bool = False) -> Path:
Returns:
Path: Celune's user data directory.
"""
- path = Path(user_data_dir(APP_SLUG, appauthor=False))
+ path = Path(user_data_dir(APP_NAME, appauthor=False))
if create:
path.mkdir(parents=True, exist_ok=True)
return path
+def huggingface_home_dir(create: bool = False) -> Path:
+ """Return Celune's default Hugging Face home directory.
+
+ Args:
+ create: Whether this directory should be created before being returned.
+
+ Returns:
+ Path: Celune's Hugging Face home directory inside the user data folder.
+ """
+ path = app_data_dir(create=create) / "huggingface"
+ if create:
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def huggingface_hub_cache_dir(create: bool = False) -> Path:
+ """Return Celune's default Hugging Face Hub snapshot cache directory.
+
+ Args:
+ create: Whether this directory should be created before being returned.
+
+ Returns:
+ Path: Celune's Hugging Face Hub cache directory.
+ """
+ path = huggingface_home_dir(create=create) / "hub"
+ if create:
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def configure_huggingface_cache_environment(force: bool = False) -> None:
+ """Point Hugging Face caches at Celune's user data directory in portable mode.
+
+ Args:
+ force: Whether to apply the portable cache defaults even outside compiled builds.
+ """
+ default_hf_home = str(huggingface_home_dir())
+ default_hf_hub_cache = str(huggingface_hub_cache_dir())
+
+ if not force and not running_compiled():
+ # If this process previously enabled Celune's portable defaults,
+ # clear them again for source-tree runs so local development and tests
+ # continue to use the host Hugging Face cache.
+ if os.environ.get(_HF_HOME_ENV) == default_hf_home:
+ os.environ.pop(_HF_HOME_ENV, None)
+ if os.environ.get(_HF_HUB_CACHE_ENV) == default_hf_hub_cache:
+ os.environ.pop(_HF_HUB_CACHE_ENV, None)
+ return
+
+ if _HF_HOME_ENV not in os.environ:
+ os.environ[_HF_HOME_ENV] = default_hf_home
+ if _HF_HUB_CACHE_ENV not in os.environ:
+ os.environ[_HF_HUB_CACHE_ENV] = default_hf_hub_cache
+
+
+def configure_huggingface_runtime() -> None:
+ """Apply Celune's process-wide Hugging Face progress suppression."""
+ os.environ.setdefault(_HF_HUB_DISABLE_PROGRESS_BARS_ENV, "1")
+ disable_progress_bar()
+ disable_progress_bars()
+
+
def memory_data_dir(create: bool = False) -> Path:
"""Return the persistent memory directory.
diff --git a/celune/persona/emotion.py b/celune/persona/emotion.py
new file mode 100644
index 0000000..09f2097
--- /dev/null
+++ b/celune/persona/emotion.py
@@ -0,0 +1,514 @@
+# SPDX-License-Identifier: MIT
+"""Emotion analysis helpers for Persona conversation state."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from collections.abc import Mapping, Sequence
+from typing import Optional, Protocol, Union, cast
+
+import torch
+import numpy as np
+import numpy.typing as npt
+from transformers.modeling_utils import PreTrainedModel
+from transformers.tokenization_utils_base import PreTrainedTokenizerBase
+from transformers import AutoModel, AutoModelForSequenceClassification, AutoTokenizer
+
+from ..utils import discard
+from ..constants import JSONSerializable, PERSONA_EMOTION_MODEL
+
+type EmbeddingVector = npt.NDArray[np.float32]
+
+GOEMOTIONS_LABELS: tuple[str, ...] = (
+ "admiration",
+ "amusement",
+ "anger",
+ "annoyance",
+ "approval",
+ "caring",
+ "confusion",
+ "curiosity",
+ "desire",
+ "disappointment",
+ "disapproval",
+ "disgust",
+ "embarrassment",
+ "excitement",
+ "fear",
+ "gratitude",
+ "grief",
+ "joy",
+ "love",
+ "nervousness",
+ "optimism",
+ "pride",
+ "realization",
+ "relief",
+ "remorse",
+ "sadness",
+ "surprise",
+ "neutral",
+)
+
+
+def _looks_like_generic_label(value: str, index: int) -> bool:
+ """Return whether one config label is a placeholder such as ``LABEL_0``."""
+ normalized = value.strip().casefold()
+ return normalized in {
+ f"label_{index}",
+ f"label {index}",
+ str(index),
+ }
+
+
+_NEGATIVE_EMOTION_TARGETS: dict[str, str] = {
+ "anger": "calm and grounding",
+ "annoyance": "patient and easing tension",
+ "confusion": "clear and grounding",
+ "disappointment": "softly encouraging",
+ "disapproval": "gentle and understanding",
+ "disgust": "careful and composed",
+ "embarrassment": "kindly reassuring",
+ "fear": "protective and reassuring",
+ "grief": "tenderly consoling",
+ "nervousness": "calm and supportive",
+ "remorse": "warmly forgiving",
+ "sadness": "gently reassuring",
+}
+_POSITIVE_EMOTION_TARGETS: dict[str, str] = {
+ "admiration": "softly admiring",
+ "amusement": "lightly playful",
+ "approval": "warmly approving",
+ "caring": "gently caring",
+ "curiosity": "gently curious",
+ "desire": "softly eager",
+ "excitement": "gently excited",
+ "gratitude": "warmly grateful",
+ "joy": "softly joyful",
+ "love": "warmly affectionate",
+ "optimism": "softly hopeful",
+ "pride": "quietly proud",
+ "realization": "softly awakened",
+ "relief": "softly relieved",
+ "surprise": "gently surprised",
+}
+
+
+@dataclass(frozen=True)
+class EmotionPrediction:
+ """One scored emotion label."""
+
+ label: str
+ score: float
+
+
+@dataclass(frozen=True)
+class EmotionAnalysis:
+ """Emotion analysis result for one message."""
+
+ embedding: EmbeddingVector
+ predictions: tuple[EmotionPrediction, ...]
+
+
+@dataclass(frozen=True)
+class PersonaEmotionState:
+ """Weighted target emotion state for Persona prompting."""
+
+ target_label: str
+ target_state: str
+ user_label: str
+ assistant_label: str
+
+
+@dataclass(frozen=True)
+class _EmotionBackend:
+ """Loaded tokenizer/model pair for one emotion model."""
+
+ tokenizer: PreTrainedTokenizerBase
+ model: PreTrainedModel
+ labels: tuple[str, ...]
+
+
+class _EmotionModelConfig(Protocol):
+ """Protocol for model configs that expose emotion label mappings."""
+
+ id2label: Mapping[Union[int, str], str]
+
+
+class PersonaEmotionAnalyzer:
+ """Analyze conversation emotion and produce a softened Persona target state."""
+
+ def __init__(
+ self,
+ model_name: str = PERSONA_EMOTION_MODEL,
+ *,
+ user_weight: float = 0.75,
+ assistant_weight: float = 0.25,
+ history_decay_power: float = 3.0,
+ ) -> None:
+ self.model_name = model_name.strip() or PERSONA_EMOTION_MODEL
+ self.user_weight = self._clamp_weight(user_weight, 0.75)
+ self.assistant_weight = self._clamp_weight(assistant_weight, 0.25)
+ self.history_decay_power = max(1.0, history_decay_power)
+ self._backend: Optional[_EmotionBackend] = None
+ self._failed = False
+ self._prototype_cache: dict[str, EmbeddingVector] = {}
+ self.last_error: str = ""
+
+ @staticmethod
+ def _clamp_weight(value: float, fallback: float) -> float:
+ """Return one normalized role weight."""
+ if isinstance(value, bool):
+ return fallback
+ if not isinstance(value, (int, float)):
+ return fallback
+ return max(0.0, float(value))
+
+ def _load_backend(self) -> Optional[_EmotionBackend]:
+ """Load the underlying model backend when available."""
+ if self._failed:
+ return None
+ if self._backend is not None:
+ return self._backend
+
+ try:
+ tokenizer = AutoTokenizer.from_pretrained(self.model_name)
+ try:
+ model = AutoModelForSequenceClassification.from_pretrained(
+ self.model_name
+ )
+ except (RuntimeError, AssertionError, ValueError, OSError):
+ model = AutoModel.from_pretrained(self.model_name)
+ model.eval()
+ model.to(torch.device("cpu"))
+ labels = self._resolve_labels(getattr(model, "config", None))
+ self.last_error = ""
+ except (RuntimeError, AssertionError, ValueError, OSError) as error:
+ self._failed = True
+ self.last_error = str(error)
+ return None
+
+ self._backend = _EmotionBackend(
+ tokenizer=tokenizer,
+ model=model,
+ labels=labels,
+ )
+ return self._backend
+
+ @staticmethod
+ def _resolve_labels(config: Optional[_EmotionModelConfig]) -> tuple[str, ...]:
+ """Return label names from model config or GoEmotions defaults."""
+ raw = None if config is None else config.id2label
+ if isinstance(raw, Mapping):
+ labels: list[tuple[int, str]] = []
+ generic_count = 0
+ for key, value in raw.items():
+ try:
+ idx = int(key)
+ except (TypeError, ValueError):
+ continue
+ if not isinstance(value, str) or not value.strip():
+ continue
+ normalized = value.strip().casefold()
+ if _looks_like_generic_label(normalized, idx):
+ generic_count += 1
+ labels.append((idx, normalized))
+ if labels:
+ labels.sort(key=lambda item: item[0])
+ if (
+ generic_count == len(labels)
+ and len(labels) == len(GOEMOTIONS_LABELS)
+ and all(
+ index == position for position, (index, _) in enumerate(labels)
+ )
+ ):
+ return GOEMOTIONS_LABELS
+ return tuple(label for _, label in labels)
+ return GOEMOTIONS_LABELS
+
+ def analyze_texts(self, texts: Sequence[str]) -> Optional[list[EmotionAnalysis]]:
+ """Return embeddings and scored labels for the requested texts.
+
+ Args:
+ texts: The texts to analyze in one batch.
+
+ Returns:
+ Optional[list[EmotionAnalysis]]: Per-text emotion results, or ``None`` when the model is unavailable.
+ """
+ backend = self._load_backend()
+ if backend is None or not texts:
+ return None
+
+ try:
+ encoded = backend.tokenizer(
+ list(texts),
+ padding=True,
+ truncation=True,
+ return_tensors="pt",
+ )
+ with torch.no_grad():
+ outputs = backend.model(
+ **encoded,
+ output_hidden_states=True,
+ return_dict=True,
+ )
+ hidden_states = cast(
+ Optional[tuple[torch.Tensor, ...]], outputs.hidden_states
+ )
+ if hidden_states is not None and hidden_states != ():
+ last_hidden = hidden_states[-1]
+ else:
+ last_hidden = cast(
+ Optional[torch.Tensor], getattr(outputs, "last_hidden_state", None)
+ )
+ if last_hidden is None:
+ self.last_error = f"{self.model_name} did not expose hidden states or last_hidden_state"
+ return None
+ attention_mask = cast(torch.Tensor, encoded["attention_mask"])
+ attention = attention_mask.unsqueeze(-1).to(last_hidden.dtype)
+ pooled = (last_hidden * attention).sum(dim=1) / attention.sum(dim=1).clamp(
+ min=1
+ )
+ normalized = torch.nn.functional.normalize(pooled, p=2, dim=1)
+ embeddings = normalized.cpu().numpy().astype(np.float32)
+ logits = cast(Optional[torch.Tensor], getattr(outputs, "logits", None))
+ probabilities = (
+ torch.sigmoid(logits).cpu().numpy().astype(np.float32)
+ if logits is not None
+ else None
+ )
+ self.last_error = ""
+ except (RuntimeError, AssertionError, ValueError, OSError, KeyError) as error:
+ self._failed = True
+ self._backend = None
+ self._prototype_cache.clear()
+ self.last_error = str(error)
+ return None
+
+ analyses: list[EmotionAnalysis] = []
+ for index, embedding in enumerate(embeddings):
+ row = probabilities[index] if probabilities is not None else None
+ predictions = self._row_predictions(row, backend.labels)
+ analyses.append(
+ EmotionAnalysis(
+ embedding=cast(EmbeddingVector, embedding),
+ predictions=predictions,
+ )
+ )
+ return analyses
+
+ @staticmethod
+ def _row_predictions(
+ row: Optional[npt.NDArray[np.float32]],
+ labels: tuple[str, ...],
+ ) -> tuple[EmotionPrediction, ...]:
+ """Return sorted label scores for one model row."""
+ if row is None:
+ return ()
+ ranked = sorted(
+ (
+ EmotionPrediction(label=label, score=float(score))
+ for label, score in zip(labels, row)
+ ),
+ key=lambda item: item.score,
+ reverse=True,
+ )
+ return tuple(ranked)
+
+ def _prototype_embeddings(self) -> Optional[dict[str, EmbeddingVector]]:
+ """Return prototype emotion embeddings keyed by label."""
+ backend = self._load_backend()
+ if backend is None:
+ return None
+ if self._prototype_cache:
+ return dict(self._prototype_cache)
+
+ prompts = [
+ f"This feels like {label.replace('_', ' ')}." for label in backend.labels
+ ]
+ analyses = self.analyze_texts(prompts)
+ if analyses is None or len(analyses) != len(prompts):
+ return None
+
+ self._prototype_cache = {
+ label: analysis.embedding
+ for label, analysis in zip(backend.labels, analyses)
+ }
+ return dict(self._prototype_cache)
+
+ @staticmethod
+ def _cosine_similarity(first: EmbeddingVector, second: EmbeddingVector) -> float:
+ """Return cosine similarity between two embedding vectors."""
+ denom = float(np.linalg.norm(first) * np.linalg.norm(second))
+ if denom <= 0:
+ return -1.0
+ return float(np.dot(first, second) / denom)
+
+ @staticmethod
+ def _blend_predictions(
+ weighted_predictions: Sequence[tuple[float, EmotionAnalysis]],
+ ) -> str:
+ """Return the strongest aggregate label across weighted predictions."""
+ scores: dict[str, float] = {}
+ for weight, analysis in weighted_predictions:
+ for prediction in analysis.predictions:
+ scores[prediction.label] = scores.get(prediction.label, 0.0) + (
+ weight * prediction.score
+ )
+ if not scores:
+ return "neutral"
+ return max(scores.items(), key=lambda item: item[1])[0]
+
+ @staticmethod
+ def _normalize_embedding(vector: EmbeddingVector) -> EmbeddingVector:
+ """Return one L2-normalized embedding vector."""
+ norm = float(np.linalg.norm(vector))
+ if norm <= 0:
+ return vector
+ return (vector / norm).astype(np.float32)
+
+ def _weighted_role_mean(
+ self,
+ entries: Sequence[tuple[float, EmotionAnalysis]],
+ ) -> Optional[EmbeddingVector]:
+ """Return one normalized weighted mean embedding for a message role."""
+ if not entries:
+ return None
+ total_weight = sum(weight for weight, _ in entries)
+ if total_weight <= 0:
+ return None
+ combined = sum(
+ analysis.embedding * np.float32(weight) for weight, analysis in entries
+ )
+ return self._normalize_embedding(
+ cast(
+ EmbeddingVector,
+ (combined / np.float32(total_weight)).astype(np.float32),
+ )
+ )
+
+ @staticmethod
+ def _message_weight(index: int, total: int, decay_power: float) -> float:
+ """Return one recency weight for a chronological message index."""
+ if total <= 0:
+ return 0.0
+ return float(((index + 1) / total) ** decay_power)
+
+ def summarize_history(
+ self,
+ history: Sequence[Mapping[str, JSONSerializable]],
+ request: str,
+ ) -> Optional[PersonaEmotionState]:
+ """Return a softened target emotion from prior chat plus the current request.
+
+ Args:
+ history: Prior Persona chat messages in chronological order.
+ request: The current user request that should be treated as the latest user emotion sample.
+
+ Returns:
+ Optional[PersonaEmotionState]: The blended Persona mood target, or ``None`` when emotion analysis is
+ unavailable.
+ """
+ entries: list[tuple[str, str]] = []
+ for message in history:
+ role = message.get("role")
+ content = message.get("content")
+ if (
+ role in {"user", "assistant"}
+ and isinstance(content, str)
+ and content.strip()
+ ):
+ entries.append((str(role), content.strip()))
+
+ clean_request = request.strip()
+ if clean_request:
+ entries.append(("user", clean_request))
+ if not entries:
+ return None
+
+ analyses = self.analyze_texts([content for _, content in entries])
+ if analyses is None or len(analyses) != len(entries):
+ return None
+
+ weighted_user: list[tuple[float, EmotionAnalysis]] = []
+ weighted_assistant: list[tuple[float, EmotionAnalysis]] = []
+ total = len(entries)
+ for index, ((role, _content), analysis) in enumerate(zip(entries, analyses)):
+ weight = self._message_weight(index, total, self.history_decay_power)
+ if role == "user":
+ weighted_user.append((weight, analysis))
+ else:
+ weighted_assistant.append((weight, analysis))
+
+ user_embedding = self._weighted_role_mean(weighted_user)
+ assistant_embedding = self._weighted_role_mean(weighted_assistant)
+ if user_embedding is None and assistant_embedding is None:
+ self.last_error = "no user or assistant emotion embeddings could be derived"
+ return None
+
+ role_mix: list[tuple[float, EmbeddingVector]] = []
+ if user_embedding is not None:
+ role_mix.append((self.user_weight, user_embedding))
+ if assistant_embedding is not None:
+ role_mix.append((self.assistant_weight, assistant_embedding))
+
+ blend_total = sum(weight for weight, _ in role_mix)
+ if blend_total <= 0:
+ return None
+ blended = sum(
+ embedding * np.float32(weight) for weight, embedding in role_mix
+ ) / np.float32(blend_total)
+ target_embedding = self._normalize_embedding(
+ cast(EmbeddingVector, blended.astype(np.float32))
+ )
+
+ target_label = self._nearest_label(target_embedding)
+ user_label = self._blend_predictions(weighted_user)
+ assistant_label = self._blend_predictions(weighted_assistant)
+ if user_label == "neutral" and user_embedding is not None:
+ user_label = self._nearest_label(user_embedding)
+ if assistant_label == "neutral" and assistant_embedding is not None:
+ assistant_label = self._nearest_label(assistant_embedding)
+ return PersonaEmotionState(
+ target_label=target_label,
+ target_state=self._render_target_state(
+ target_label,
+ user_label,
+ assistant_label,
+ ),
+ user_label=user_label,
+ assistant_label=assistant_label,
+ )
+
+ def _nearest_label(self, target_embedding: EmbeddingVector) -> str:
+ """Return the nearest emotion label for one blended embedding."""
+ prototypes = self._prototype_embeddings()
+ if not prototypes:
+ return "neutral"
+ best_label = "neutral"
+ best_score = -1.0
+ for label, embedding in prototypes.items():
+ score = self._cosine_similarity(target_embedding, embedding)
+ if score > best_score:
+ best_label = label
+ best_score = score
+ return best_label
+
+ @staticmethod
+ def _render_target_state(
+ target_label: str,
+ user_label: str,
+ assistant_label: str,
+ ) -> str:
+ """Return the Persona prompt text for one target emotion blend."""
+ discard(user_label)
+ discard(assistant_label)
+ if target_label in _NEGATIVE_EMOTION_TARGETS:
+ softened = _NEGATIVE_EMOTION_TARGETS[target_label]
+ return f"Target emotion: {softened}."
+
+ if target_label in _POSITIVE_EMOTION_TARGETS:
+ softened = _POSITIVE_EMOTION_TARGETS[target_label]
+ return f"Target emotion: {softened}."
+
+ return f"Target emotion: softly {target_label.replace('_', ' ')} and grounded."
diff --git a/celune/persona/impl.py b/celune/persona/impl.py
index 10352de..ee0f5da 100644
--- a/celune/persona/impl.py
+++ b/celune/persona/impl.py
@@ -249,7 +249,7 @@ def uses_default_celune_identity(engine: PersonaEngineView) -> bool:
"""
if not bool(getattr(engine, "voice_bundle_is_default", False)):
return False
- return persona_active_character_name(engine).strip().lower() == "celune"
+ return persona_active_character_name(engine).strip().casefold() == "celune"
def default_persona_persona() -> str:
diff --git a/celune/persona/memory.py b/celune/persona/memory.py
index 528f9ec..146c04a 100644
--- a/celune/persona/memory.py
+++ b/celune/persona/memory.py
@@ -125,7 +125,7 @@ def _load_transformer_text_embedder(model_name: str) -> Optional[_EmbeddingBacke
model = AutoModel.from_pretrained(model_name, local_files_only=True)
model.eval()
model.to(torch.device("cpu"))
- except (RuntimeError, AssertionError, ValueError):
+ except (RuntimeError, AssertionError, ValueError, OSError):
_FAILED_EMBEDDING_MODELS.add(model_name)
return None
@@ -160,7 +160,7 @@ def _compute_text_embeddings(
normalized = f.normalize(pooled, p=2, dim=1)
array = normalized.cpu().numpy().astype(np.float32)
return [cast(EmbeddingVector, row) for row in array]
- except (RuntimeError, AssertionError, ValueError):
+ except (RuntimeError, AssertionError, ValueError, OSError):
_FAILED_EMBEDDING_MODELS.add(model_name)
_EMBEDDING_BACKENDS.pop(model_name, None)
return None
diff --git a/celune/persona/prompts.py b/celune/persona/prompts.py
index 1d99682..c1647b3 100644
--- a/celune/persona/prompts.py
+++ b/celune/persona/prompts.py
@@ -1,13 +1,10 @@
# SPDX-License-Identifier: MIT
"""Structured prompt building for the Persona system."""
-import textwrap
-from pathlib import Path
+import contextlib
from dataclasses import dataclass, field
-from platformdirs import user_data_dir
-
-from ..constants import APP_SLUG
+from ..paths import temp_data_dir
def _render_lines(lines: list[str]) -> str:
@@ -50,6 +47,24 @@ def render(self) -> str:
lines.extend(["", "Profile:", self.profile.strip()])
return "\n".join(lines)
+ def render_identity_summary(self) -> str:
+ """Return a compact identity summary for the runtime prompt.
+
+ Returns:
+ str: A concise identity block for the active character.
+ """
+ name = self.name.strip() or "Unknown"
+ profile = self.profile.strip()
+ sep = ", " if profile else "."
+ if profile:
+ profile = profile[0].lower() + profile[1:]
+
+ lines = [
+ f"You are {name}{sep}{profile}",
+ f"When asked for an introduction, refer to yourself as {name}.",
+ ]
+ return "\n".join(lines)
+
@dataclass(frozen=True)
class PersonaCard:
@@ -105,6 +120,29 @@ def render(self) -> str:
lines.extend(["", "Voice:", self.voice.strip()])
return "\n".join(lines)
+ def behavior_cues(self) -> tuple[str, ...]:
+ """Return concise CEVOICE-driven behavior cues for the runtime prompt.
+
+ Returns:
+ tuple[str, ...]: Prompt lines derived from CEVOICE persona metadata.
+ """
+ cues: list[str] = []
+ if self.speaking_style.strip():
+ cues.append(self.speaking_style.strip())
+
+ rules: list[str] = []
+ for item in self.boundaries:
+ stripped = item.strip()
+ if stripped:
+ rules.append(stripped)
+ for item in self.prompt_rules:
+ stripped = item.strip()
+ if stripped and stripped not in rules:
+ rules.append(stripped)
+ if rules:
+ cues.append("\n- ".join(rules[:2]))
+ return tuple(cues)
+
@dataclass(frozen=True)
class RetrievedMemoryBundle:
@@ -153,11 +191,6 @@ def render(self, message: str) -> str:
None,
)
- # HACK: don't repeat this
- # the characters loved to repeat themselves for no apparent reason
- # thanks Qwen for me having to prompt engineer around this issue with both ChatGPT and Claude
- #
- # Qwen3-VL is also prone to this, the prompt probably sucks
if last_assistant:
lines.append(
"[The assistant has already acknowledged the complaint about repetition. "
@@ -168,40 +201,32 @@ def render(self, message: str) -> str:
return _render_lines(lines)
-@dataclass(frozen=True)
-class VisualContext:
- """Optional visual context for the current request."""
-
- items: tuple[str, ...] = ()
-
- def render(self) -> str:
- """Return the visual context block.
-
- Returns:
- str: The formatted visual context block.
- """
- return _render_lines([f"- {item}" for item in self.items])
-
-
@dataclass(frozen=True)
class PersonaContext:
"""Structured context passed into the Persona prompt builder."""
character_profile: CharacterProfile
persona_card: PersonaCard
- relationship_memory: str
mood_or_state: str
retrieved_long_term_memory: RetrievedMemoryBundle = field(
default_factory=RetrievedMemoryBundle
)
current_run_chat_history: ShortTermHistory = field(default_factory=ShortTermHistory)
- visual_context: VisualContext = field(default_factory=VisualContext)
user_message: str = ""
class PersonaPromptBuilder:
"""Build structured runtime prompts for the Persona system."""
+ @staticmethod
+ def _write_debug_prompt(prompt: str) -> None:
+ """Persist the current Persona prompt when the temp directory is writable."""
+ with contextlib.suppress(OSError):
+ (temp_data_dir(create=True) / "rag_prompt.txt").write_text(
+ prompt,
+ encoding="utf-8",
+ )
+
@staticmethod
def build(context: PersonaContext) -> str:
"""Return the structured Persona runtime prompt.
@@ -212,76 +237,51 @@ def build(context: PersonaContext) -> str:
Returns:
str: The formatted RAG prompt for persona.
"""
+ name = context.character_profile.name.strip() or "Unknown"
+ behavior_lines = [
+ f"- Respond only as {name}.",
+ "- Continue directly from .",
+ "- Push the conversation forward instead of returning to earlier turns.",
+ "- Treat facts in as true context when they are relevant.",
+ (
+ "- Keep items from silent unless the current user "
+ "message clearly asks for them or they are necessary for a natural reply."
+ ),
+ "- Do not greet the user. Do not ask what they need. Just respond.",
+ "- Do not repeat anything already said in .",
+ "- Do not bring up older messages, stored facts, or resolved topics on your own.",
+ "- Keep the reply natural, short, and emotionally consistent with .",
+ "- Stay under 3 sentences, unless the user asked for detail.",
+ "- Use only a single paragraph, unless formatting is necessary.",
+ ]
+ behavior_lines.extend(
+ f"- {cue}." if not cue.endswith((".", "!", "?")) else f"- {cue}"
+ for cue in context.persona_card.behavior_cues()
+ )
sections = [
- textwrap.dedent(
- f"""
-
- You are {context.character_profile.name}. Respond only as {context.character_profile.name}.
-
- Rules:
- 1. Read the conversation in before writing anything.
- 2. Never repeat a sentence, phrase, or idea you already said in that history.
- 3. Facts in are true. Use them. Do not contradict them.
- 4. Stay under 3 sentences unless the user asked a detailed question.
- 5. You are not an AI assistant. Do not offer help. Just talk.
- 6. Never use "assist", "help you today", "what can I do for you", or similar service-session phrasing,
- unless the character identity explicitly requires it.
-
- """
- ).strip(),
_render_optional_section(
- "character_identity",
- context.character_profile.render(),
+ "profile",
+ context.character_profile.render_identity_summary(),
),
_render_optional_section(
- "long_term_memory",
+ "memories",
context.retrieved_long_term_memory.render(),
),
_render_optional_section(
- "persona_style",
- context.persona_card.render(),
- ),
- # the following two fields are unused
- _render_optional_section(
- "relationship_to_user",
- context.relationship_memory.strip() or "none",
- ),
- _render_optional_section(
- "current_state",
- context.mood_or_state.strip() or "neutral",
+ "history",
+ context.current_run_chat_history.render(context.user_message.strip()),
),
_render_optional_section(
- "short_term_memory",
- context.current_run_chat_history.render(context.user_message.strip()),
+ "mood",
+ context.mood_or_state.strip() or "Target emotion: neutral.",
),
_render_optional_section(
- "vision_context",
- context.visual_context.render(),
+ "behavior",
+ _render_lines(behavior_lines),
),
- textwrap.dedent(
- f"""
-
- - Continue the conversation from as {context.character_profile.name}.
- - Match the tone and length of the example dialogue if provided.
- - Do not greet the user. Do not ask what they need. Just respond.
- - Do not repeat anything already said in .
- - If you don't know something, say so in character — don't invent facts.
- - One topic per response. Be direct.
-
- """
- ).strip(),
- f"{context.character_profile.name}:",
+ f"{name}:",
]
- # this is for inspecting your RAG prompt, in case your character goes off the guidelines
- # it is located in the following paths:
- # %localappdata%\celune\temp\rag_prompt.txt on Windows, or
- # ~/.local/share/celune/temp/rag_prompt.txt on Linux
- with open(
- Path(user_data_dir(APP_SLUG, appauthor=False)) / "temp" / "rag_prompt.txt",
- "w",
- encoding="utf-8",
- ) as f:
- f.write("\n\n".join(section for section in sections if section))
-
- return "\n\n".join(section for section in sections if section)
+ prompt = "\n\n".join(section for section in sections if section)
+ PersonaPromptBuilder._write_debug_prompt(prompt)
+ return prompt
diff --git a/celune/persona/runtime.py b/celune/persona/runtime.py
index 19ec0e4..87bf33e 100644
--- a/celune/persona/runtime.py
+++ b/celune/persona/runtime.py
@@ -5,8 +5,8 @@
import gc
import threading
import contextlib
-from collections.abc import Mapping, Sequence
from typing import Optional, Union, cast
+from collections.abc import Mapping, Sequence
import torch
from transformers.tokenization_utils_base import BatchEncoding
@@ -18,9 +18,14 @@
BitsAndBytesConfig,
)
-from ..utils import discard, normalize_special_characters
from ..vram import resolve_vram_preset
-from ..constants import JSONSerializable, PERSONA_MODEL_ID, N_A_STR
+from ..utils import discard, normalize_special_characters
+from ..constants import (
+ JSONSerializable,
+ PERSONA_MODEL_ID,
+ N_A_STR,
+ remote_code_model_revision,
+)
from ..dataclasses.persona import ChatMessage, GenerateRequest, GenerateResponse
from ..typing.persona import (
ChatMessagePayload,
@@ -118,10 +123,12 @@ def load(self, model_id: str, quantization: str) -> None:
return
self.unload()
+ revision = remote_code_model_revision(model_id)
config = AutoConfig.from_pretrained(
model_id,
trust_remote_code=True,
+ revision=revision,
)
model_type = getattr(config, "model_type", N_A_STR)
wanted_type = "qwen3_vl"
@@ -140,6 +147,7 @@ def load(self, model_id: str, quantization: str) -> None:
Qwen3VLForConditionalGeneration.from_pretrained(
model_id,
trust_remote_code=True,
+ revision=revision,
device_map="auto",
quantization_config=BitsAndBytesConfig(
load_in_4bit=True,
@@ -157,6 +165,7 @@ def load(self, model_id: str, quantization: str) -> None:
Qwen3VLForConditionalGeneration.from_pretrained(
model_id,
trust_remote_code=True,
+ revision=revision,
device_map="auto",
quantization_config=BitsAndBytesConfig(load_in_8bit=True),
),
@@ -167,6 +176,7 @@ def load(self, model_id: str, quantization: str) -> None:
Qwen3VLForConditionalGeneration.from_pretrained(
model_id,
trust_remote_code=True,
+ revision=revision,
device_map="auto",
torch_dtype=torch.bfloat16,
),
@@ -180,6 +190,7 @@ def load(self, model_id: str, quantization: str) -> None:
AutoProcessor.from_pretrained(
model_id,
trust_remote_code=True,
+ revision=revision,
),
)
except Exception as exc:
@@ -195,6 +206,7 @@ def load(self, model_id: str, quantization: str) -> None:
AutoTokenizer.from_pretrained(
model_id,
trust_remote_code=True,
+ revision=revision,
),
)
diff --git a/celune/pipeline.py b/celune/pipeline.py
index 6998fba..5b72d27 100644
--- a/celune/pipeline.py
+++ b/celune/pipeline.py
@@ -5,20 +5,20 @@
import os
import re
-import json
import sys
+import json
import time
import queue
import random
import pathlib
import datetime
-import contextlib
import subprocess
-from importlib import util as importlib_util
+import contextlib
from collections import deque
-from typing import TYPE_CHECKING, Optional, Mapping, Union, cast
-from urllib.parse import urlparse, urlencode
from urllib.request import urlopen
+from urllib.parse import urlparse, urlencode
+from importlib import util as importlib_util
+from typing import TYPE_CHECKING, Optional, Mapping, Union, cast
import torch
import numpy as np
@@ -31,13 +31,18 @@
from . import __version__
from .dataclasses.pipeline import (
+ AudioOutput,
+ AudioInputRequest,
PlaybackChunk,
PlaybackSourceDone,
SpeechRequest,
SpeechTiming,
+ VoiceConversionRequest,
)
from .exceptions import NotAvailableError
+from .config import resolve_audio_device
from .persona.memory import PersonaMemoryStore
+from .persona.emotion import PersonaEmotionAnalyzer
from .analysis import analyze_voice_audio
from .paths import app_data_dir, project_root, running_compiled
from .persona.impl import (
@@ -57,7 +62,9 @@
persona_short_term_history_limit,
persona_style_traits,
)
+from .cevoice import default_loader
from .dsp import (
+ pitch_shift_audio,
resample_audio,
soften,
split,
@@ -83,18 +90,20 @@
PersonaPromptBuilder,
RetrievedMemoryBundle,
ShortTermHistory,
- VisualContext,
)
from .constants import (
APP_NAME,
APP_SLUG,
BASE_SR,
JSON,
+ PERSONA_EMOTION_MODEL,
JSONSerializable,
PERSONA_MEMORY_EMBEDDING_MODEL,
)
+from .i18n import string
from .typing.pipeline import SpeechStreamQueue
+
if TYPE_CHECKING:
from .celune import Celune
@@ -106,6 +115,8 @@
_SFX_DUCK_FADE_SECONDS = 0.15
_LEGACY_BUFFER_SECONDS = 10.0
_SMART_BUFFER_REALTIME_SPEED = 1.05
+
+
_SMART_BUFFER_PROTECTED_PLAYBACK_SECONDS = 20.0
_SMART_BUFFER_MIN_SECONDS = 0.35
_SMART_BUFFER_MIN_SPEED_SAMPLE_SECONDS = 0.75
@@ -406,7 +417,7 @@ def log_first_playback(engine: Celune, timing: Optional[SpeechTiming]) -> None:
else:
elapsed = time.monotonic() - start_time
- engine.log(f"TTFP: {format_number(elapsed, 2)} seconds")
+ engine.log(string("pipeline.ttfp_seconds", seconds=format_number(elapsed, 2)))
def close_stream(engine: Celune, abort: bool = False) -> None:
@@ -455,7 +466,7 @@ def force_stop_speech(engine: Celune) -> bool:
engine.utterance_force_stop.clear()
return False
- engine.log("Forcefully stopping speech.")
+ engine.log(string("pipeline.forcefully_stopping_speech"))
engine.utterance_force_stop.set()
with engine.queue_lock:
@@ -480,8 +491,11 @@ def acquire_pipeline(engine: Celune, action: str) -> bool:
with engine.say_lock:
engine.log_dev(f"[LOCK] acquire requested by {action}, locked={engine.locked}")
if engine.locked:
- engine.log(f"Tried to {action} while {APP_NAME} was busy.", "warning")
- engine.error_callback(f"{APP_NAME} is currently busy")
+ engine.log(
+ string("pipeline.busy_action", action=action, app_name=APP_NAME),
+ "warning",
+ )
+ engine.error_callback(string("celune.app_busy", app_name=APP_NAME))
return False
engine.locked = True
@@ -517,11 +531,21 @@ def _next_playback_source_id(engine: Celune) -> int:
def _register_overlay_playback(engine: Celune) -> None:
"""Mark the mixer busy for a newly queued non-speech playback source."""
+ _register_overlay_playback_state(engine, reset_ready_announcement=True)
+
+
+def _register_overlay_playback_state(
+ engine: Celune,
+ *,
+ reset_ready_announcement: bool,
+) -> None:
+ """Mark the mixer busy for overlay playback with optional ready reset."""
with engine.say_lock:
if not engine.locked:
engine.cur_state = "speaking"
engine.playback_done.clear()
- engine._ready_announced = False
+ if reset_ready_announcement:
+ engine._ready_announced = False
def _playback_source_statuses(engine: Celune) -> dict[int, str]:
@@ -693,6 +717,7 @@ def _queue_playback_done(
source_id: int,
*,
release_pipeline_when_finished: bool = False,
+ notify_idle_when_finished: bool = True,
saved_path: Optional[str] = None,
analysis_audio: Optional[npt.NDArray[np.float32]] = None,
) -> None:
@@ -701,12 +726,48 @@ def _queue_playback_done(
PlaybackSourceDone(
source_id=source_id,
release_pipeline=release_pipeline_when_finished,
+ notify_idle=notify_idle_when_finished,
saved_path=saved_path,
analysis_audio=analysis_audio,
)
)
+def _flush_buffered_speech_chunks(
+ engine: Celune,
+ source_id: int,
+ buffer: list[npt.NDArray[np.float32]],
+ speech_timing: SpeechTiming,
+ pushed_audio: bool,
+ stream_queue: Optional[SpeechStreamQueue],
+) -> bool:
+ """Queue buffered speech chunks without merging them into a larger copy."""
+ if not buffer:
+ return pushed_audio
+
+ first_buffer_chunk = True
+ for queued_audio in buffer:
+ _queue_playback_chunk(
+ engine,
+ source_id,
+ queued_audio,
+ BASE_SR,
+ speech_timing if not pushed_audio and first_buffer_chunk else None,
+ )
+ if stream_queue is not None:
+ stream_queue.put(queued_audio.copy())
+ first_buffer_chunk = False
+
+ buffer.clear()
+ if not pushed_audio:
+ _set_playback_source_status(engine, source_id, string("status.speaking"))
+ engine.cur_state = "speaking"
+ engine.queue_avail_callback()
+ return True
+
+ return pushed_audio
+
+
def _youtube_sfx_temp_path() -> pathlib.Path:
"""Return the fixed temporary WAV path used for URL-backed SFX playback."""
return app_data_dir(create=True) / "temp" / "temporary_audio.wav"
@@ -745,8 +806,8 @@ def _download_youtube_sfx(
"""Download one YouTube URL as a temporary WAV file for SFX playback."""
yt_dlp_module = "yt_dlp"
if importlib_util.find_spec(yt_dlp_module) is None:
- engine.log("yt-dlp is not installed, cannot play YouTube audio.", "warning")
- engine.error_callback("yt-dlp is required for YouTube playback")
+ engine.log(string("pipeline.yt_dlp_missing"), "warning")
+ engine.error_callback(string("pipeline.yt_dlp_required"))
return None
output_path = _youtube_sfx_temp_path()
@@ -756,8 +817,8 @@ def _download_youtube_sfx(
title = _youtube_sfx_title(url)
out_tmpl = str(output_path.with_suffix(".%(ext)s"))
- engine.status_callback("Downloading audio")
- engine.log(f"[SFX] Downloading audio from {url}...")
+ engine.status_callback(string("status.downloading_audio"))
+ engine.log(string("pipeline.youtube_download_start", url=url))
python_executable = sys.executable
if running_compiled():
if os.name == "nt":
@@ -792,20 +853,20 @@ def _download_youtube_sfx(
stderr = (
completed.stderr.strip() or completed.stdout.strip() or "unknown error"
)
- engine.log("Could not download audio.", "warning")
+ engine.log(string("pipeline.download_failed"), "warning")
engine.log(stderr, "warning")
- engine.error_callback("Could not download YouTube audio")
+ engine.error_callback(string("pipeline.download_youtube_failed_short"))
return None
except subprocess.TimeoutExpired:
- engine.log("Timed out downloading audio.", "warning")
- engine.error_callback("Could not download YouTube audio")
+ engine.log(string("pipeline.download_timeout"), "warning")
+ engine.error_callback(string("pipeline.download_youtube_failed_short"))
return None
if not output_path.exists():
stderr = completed.stderr.strip() or completed.stdout.strip() or "unknown error"
- engine.log("Downloader returned no file.", "warning")
+ engine.log(string("pipeline.downloader_no_file"), "warning")
engine.log(stderr, "warning")
- engine.error_callback("Could not download YouTube audio")
+ engine.error_callback(string("pipeline.download_youtube_failed_short"))
return None
return output_path, title
@@ -989,89 +1050,6 @@ def build_persona_character_card(engine: Celune) -> str:
return f"{context.character_profile.render()}\n\n{context.persona_card.render()}"
-def _build_visual_context(engine: Celune) -> VisualContext:
- """Return the optional visual context summary for the current request."""
- remembered = _recent_visual_context_items(engine)
- attachments = getattr(engine, "persona_attachments", [])
- if not isinstance(attachments, list):
- return VisualContext(items=remembered)
-
- items = list(remembered)
- for attachment in attachments:
- if not isinstance(attachment, dict):
- continue
- kind = attachment.get("type")
- name = attachment.get("name")
- path = attachment.get("path")
- if not isinstance(kind, str) or not kind.strip():
- continue
- label = name.strip() if isinstance(name, str) and name.strip() else ""
- source = path.strip() if isinstance(path, str) and path.strip() else ""
- if label and source:
- items.append(f"{kind.strip()}: {label} ({source})")
- elif label:
- items.append(f"{kind.strip()}: {label}")
- elif source:
- items.append(f"{kind.strip()}: {source}")
-
- return VisualContext(items=tuple(items))
-
-
-def _recent_visual_context_items(engine: Celune) -> tuple[str, ...]:
- """Return textual carry-over context from the most recent visual request."""
- items = getattr(engine, "persona_recent_visual_context", ())
- if isinstance(items, str):
- stripped = items.strip()
- return (stripped,) if stripped else ()
- if isinstance(items, (list, tuple)):
- return tuple(
- item.strip() for item in items if isinstance(item, str) and item.strip()
- )
- return ()
-
-
-def _remember_visual_context(
- attachments: list[JSONSerializable],
- engine: Celune,
- request: str,
-) -> None:
- """Store a text summary for the most recent one-shot visual request."""
- if not isinstance(attachments, list):
- setattr(engine, "persona_recent_visual_context", ())
- return
-
- media_items: list[str] = []
- for attachment in attachments:
- if not isinstance(attachment, dict):
- continue
- kind = attachment.get("type")
- name = attachment.get("name")
- path = attachment.get("path")
- if not isinstance(kind, str) or not kind.strip():
- continue
- label = name.strip() if isinstance(name, str) and name.strip() else ""
- source = path.strip() if isinstance(path, str) and path.strip() else ""
- if label and source:
- media_items.append(f"{kind.strip()}: {label} ({source})")
- elif label:
- media_items.append(f"{kind.strip()}: {label}")
- elif source:
- media_items.append(f"{kind.strip()}: {source}")
-
- if not media_items:
- setattr(engine, "persona_recent_visual_context", ())
- return
-
- remembered = [
- "Recent visual context from the last Persona request:",
- *media_items,
- ]
- clean_request = request.strip()
- if clean_request:
- remembered.append(f"User request about that media: {clean_request}")
- setattr(engine, "persona_recent_visual_context", tuple(remembered))
-
-
def _build_short_term_history(engine: Celune) -> ShortTermHistory:
"""Return the current-run chat history for the Persona prompt."""
messages = persona_history_messages(engine)
@@ -1089,6 +1067,72 @@ def _build_short_term_history(engine: Celune) -> ShortTermHistory:
return ShortTermHistory(turns=tuple(turns), session_summary=session_summary)
+def _persona_emotion_analyzer(engine: Celune) -> Optional[PersonaEmotionAnalyzer]:
+ """Return the configured Persona emotion analyzer for this engine."""
+ existing = getattr(engine, "persona_emotion_analyzer", None)
+ if isinstance(existing, PersonaEmotionAnalyzer):
+ return existing
+
+ emotion_config = persona_config(engine.config).get("emotion")
+ if isinstance(emotion_config, dict):
+ enabled = emotion_config.get("enabled", True)
+ if isinstance(enabled, bool) and not enabled:
+ return None
+ model_name = emotion_config.get("model")
+ user_weight = emotion_config.get("user_weight", 0.75)
+ assistant_weight = emotion_config.get("assistant_weight", 0.25)
+ decay_power = emotion_config.get("history_decay_power", 3.0)
+ analyzer = PersonaEmotionAnalyzer(
+ model_name=model_name.strip()
+ if isinstance(model_name, str) and model_name.strip()
+ else PERSONA_EMOTION_MODEL,
+ user_weight=float(user_weight)
+ if isinstance(user_weight, (int, float))
+ and not isinstance(user_weight, bool)
+ else 0.75,
+ assistant_weight=float(assistant_weight)
+ if isinstance(assistant_weight, (int, float))
+ and not isinstance(assistant_weight, bool)
+ else 0.25,
+ history_decay_power=float(decay_power)
+ if isinstance(decay_power, (int, float))
+ and not isinstance(decay_power, bool)
+ else 3.0,
+ )
+ else:
+ analyzer = PersonaEmotionAnalyzer()
+
+ setattr(engine, "persona_emotion_analyzer", analyzer)
+ return analyzer
+
+
+def _persona_mood_or_state(
+ engine: Celune,
+ request: str,
+) -> str:
+ """Return the Persona state string for the current request."""
+ configured_state = _config_text(engine, "persona_state", "")
+ if configured_state:
+ return configured_state
+
+ analyzer = _persona_emotion_analyzer(engine)
+ if analyzer is None:
+ return "Neutral."
+
+ summary = analyzer.summarize_history(persona_history_messages(engine), request)
+ if summary is None or not summary.target_state.strip():
+ emotion_warning = (
+ f"Persona emotion analysis fell back to Neutral: {analyzer.last_error}"
+ if analyzer.last_error.strip()
+ else "Persona emotion analysis fell back to Neutral."
+ )
+ log_dev = getattr(engine, "log_dev", None)
+ if callable(log_dev):
+ log_dev(emotion_warning, "warning")
+ return "Neutral."
+ return summary.target_state
+
+
def _persona_memory_store(engine: Celune) -> Optional[PersonaMemoryStore]:
"""Return the configured Persona memory store for this engine."""
existing = getattr(engine, "persona_memory_store", None)
@@ -1236,17 +1280,14 @@ def build_persona_context(engine: Celune, request: str) -> PersonaContext:
prompt_rules=pack_persona_lines(engine, "prompt_rules"),
example_dialogue=pack_persona_lines(engine, "example_dialogue"),
)
- relationship_memory = _config_text(engine, "persona_relationship_memory", "")
- mood_or_state = _config_text(engine, "persona_state", "Neutral.")
+ mood_or_state = _persona_mood_or_state(engine, request)
return PersonaContext(
character_profile=character_profile,
persona_card=persona_card,
- relationship_memory=relationship_memory or "None.",
mood_or_state=mood_or_state,
retrieved_long_term_memory=_build_retrieved_memory_bundle(engine, request),
current_run_chat_history=_build_short_term_history(engine),
- visual_context=_build_visual_context(engine),
user_message=request.strip(),
)
@@ -1363,12 +1404,11 @@ def think(engine: Celune, request: str) -> bool:
_store_persona_memories(engine, request)
payload = build_persona_request(engine, request)
attachments = getattr(engine, "persona_attachments", None)
- attachment_snapshot = list(attachments) if isinstance(attachments, list) else []
try:
vision = engine.vision
if vision is None:
- engine.log("Persona system is not connected.", "warning")
+ engine.log(string("pipeline.persona_not_connected"), "warning")
return False
response = vision.post(json=payload)
@@ -1376,7 +1416,11 @@ def think(engine: Celune, request: str) -> bool:
spoken_text = _extract_persona_text(response.json())
except Exception as e:
engine.log(
- f"Persona system request failed: {format_error(e, engine.dev)}", "warning"
+ string(
+ "pipeline.persona_request_failed",
+ error=format_error(e, engine.dev),
+ ),
+ "warning",
)
return False
finally:
@@ -1384,11 +1428,9 @@ def think(engine: Celune, request: str) -> bool:
attachments.clear()
if not spoken_text:
- engine.log("Persona system returned an empty response.", "warning")
+ engine.log(string("pipeline.persona_empty_response"), "warning")
return False
- _remember_visual_context(attachment_snapshot, engine, request)
-
history = getattr(engine, "persona_history", None)
if isinstance(history, list):
history.extend(
@@ -1431,6 +1473,108 @@ def say(
)
+def handle_audio_input(engine: Celune, request: AudioInputRequest) -> bool:
+ """Accept engine-level audio input and route it according to the active mode.
+
+ Args:
+ engine: The Celune engine receiving the audio input.
+ request: The submitted audio input request.
+
+ Returns:
+ bool: ``True`` when the request was accepted, otherwise ``False``.
+ """
+ audio = np.asarray(request.audio, dtype=np.float32)
+ if getattr(engine, "input_mode", "text_to_speech") == "voice_conversion":
+ output = convert_audio_input(engine, request)
+ if output is None:
+ return False
+ return queue_sfx_audio(
+ engine,
+ output.audio,
+ output.sample_rate,
+ output.label,
+ log_length=request.log_playback,
+ reset_ready_announcement=request.reset_ready_announcement,
+ )
+
+ engine.log_dev(
+ "Audio input was accepted but ignored because the current mode is text-to-speech only."
+ f" label={request.label!r} sample_rate={request.sample_rate} shape={audio.shape!r}"
+ )
+ return True
+
+
+def convert_audio_input(
+ engine: Celune, request: AudioInputRequest
+) -> Optional[AudioOutput]:
+ """Run one VC conversion request and return the converted audio output.
+
+ Args:
+ engine: The Celune engine receiving the audio input.
+ request: The submitted audio input request.
+
+ Returns:
+ Optional[AudioOutput]: The converted audio output, or ``None`` when voice conversion is unavailable.
+ """
+ backend = getattr(engine, "vc_backend", None)
+ if backend is None:
+ engine.log(string("pipeline.vc_backend_unconfigured"), "warning")
+ engine.error_callback(string("pipeline.vc_backend_unconfigured"))
+ engine.progress_callback(0, 1)
+ return None
+
+ target_references: tuple[pathlib.Path, ...] = ()
+ current_voice = getattr(engine, "current_voice", None)
+ if isinstance(current_voice, str) and current_voice.strip():
+ loader = default_loader()
+ if loader is not None:
+ try:
+ target_references = (loader.materialize(current_voice, "wav"),)
+ except Exception as e:
+ engine.log(
+ string(
+ "pipeline.vc_reference_load_failed",
+ error=format_error(e, getattr(engine, "dev", False)),
+ ),
+ "warning",
+ )
+ target_references = ()
+
+ output = backend.convert(
+ VoiceConversionRequest(
+ source_audio=np.asarray(request.audio, dtype=np.float32),
+ sample_rate=request.sample_rate,
+ target_voice=getattr(engine, "current_voice", None),
+ target_character=getattr(engine, "current_character", None),
+ target_references=target_references,
+ label=request.label,
+ pitch_shift=0,
+ f0_condition=(
+ request.f0_condition
+ if isinstance(request.f0_condition, bool)
+ else getattr(engine, "vc_f0_condition", False)
+ ),
+ )
+ )
+ resolved_pitch_shift = (
+ request.pitch_shift
+ if isinstance(request.pitch_shift, int)
+ else getattr(engine, "vc_pitch_shift", 0)
+ )
+ if resolved_pitch_shift == 0:
+ return output
+
+ return AudioOutput(
+ audio=pitch_shift_audio(
+ np.asarray(output.audio, dtype=np.float32),
+ output.sample_rate,
+ resolved_pitch_shift,
+ ),
+ sample_rate=output.sample_rate,
+ label=output.label,
+ )
+
+
def queue_speech(
engine: Celune,
text: str,
@@ -1454,25 +1598,28 @@ def queue_speech(
Exception: An exception was caught and subsequently raised to propagate it to Celune.
"""
if engine.is_in_tutorial:
- engine.log("Speech input is disabled during the tutorial.", "warning")
+ engine.log(string("celune.speech_input_disabled_tutorial"), "warning")
return False
if getattr(engine, "sleeping", False):
- engine.log(f"Cannot speak while {APP_NAME} is sleeping.", "warning")
- engine.error_callback(f"{APP_NAME} is currently sleeping")
+ engine.log(
+ string("pipeline.cannot_speak_sleeping", app_name=APP_NAME),
+ "warning",
+ )
+ engine.error_callback(string("celune.app_sleeping", app_name=APP_NAME))
engine.progress_callback(0, 1)
return False
if not engine.model_ready.is_set():
- engine.status_callback("Waiting for model")
+ engine.status_callback(string("status.waiting_for_model"))
engine.progress_callback(None, None)
- engine.log("Speak request is waiting for model reload to finish.", "info")
+ engine.log(string("pipeline.speak_waiting_reload"), "info")
engine.model_ready.wait()
- if not engine.loaded:
- engine.log("Core engine is not loaded.", "warning")
- engine.error_callback(f"{APP_NAME} is not currently ready")
+ if not engine.loaded and not getattr(engine.backend, "is_fake", False):
+ engine.log(string("ui.core_engine_not_loaded"), "warning")
+ engine.error_callback(string("pipeline.not_ready_app", app_name=APP_NAME))
engine.progress_callback(0, 1)
return False
@@ -1498,10 +1645,12 @@ def queue_speech(
language = language_meta["language"]
engine.log(
- f"Received unsupported input in the following language: {language}",
+ string("pipeline.received_unsupported_language", language=language),
"warning",
)
- engine.log(f"{APP_NAME} may not say the input properly.", "warning")
+ engine.log(
+ string("pipeline.may_not_say_properly", app_name=APP_NAME), "warning"
+ )
if is_april_fools() and os.getenv("CELUNE_DISABLE_APRIL_FOOLS") not in {
"1",
@@ -1510,7 +1659,7 @@ def queue_speech(
"yes",
"enabled",
}:
- engine.log("We are about to do a funny!")
+ engine.log(string("pipeline.april_fools"))
text = rng_replace(text, targets=["celune"], replacements=["celine"])
if not acquire_pipeline(engine, "speak"):
@@ -1518,9 +1667,9 @@ def queue_speech(
return False
try:
- if not engine.loaded:
- engine.log("Core engine is not loaded.", "warning")
- engine.error_callback(f"{APP_NAME} is not currently ready")
+ if not engine.loaded and not engine.backend.is_fake:
+ engine.log(string("ui.core_engine_not_loaded"), "warning")
+ engine.error_callback(string("pipeline.not_ready_app", app_name=APP_NAME))
release_pipeline(engine)
engine.progress_callback(0, 1)
return False
@@ -1536,7 +1685,7 @@ def queue_speech(
normalize=engine.use_normalization,
)
)
- engine.status_callback("Generating")
+ engine.status_callback(string("status.generating"))
engine.progress_callback(None, None)
return True
except Exception:
@@ -1551,6 +1700,8 @@ def queue_sfx_audio(
label: str,
keep: bool = False,
volume: float = 1.0,
+ log_length: bool = True,
+ reset_ready_announcement: bool = True,
) -> bool:
"""Queue decoded SFX audio through Celune's playback pipeline.
@@ -1561,6 +1712,8 @@ def queue_sfx_audio(
label: Human-readable label for logs and status.
keep: Whether to prepend this SFX to the next saved utterance.
volume: Gain multiplier applied before the clip is queued for playback.
+ log_length: Whether to log the prepared playback sample rate and length.
+ reset_ready_announcement: Whether this source should trigger a later ready announcement.
Returns:
bool: ``True`` when playback was queued successfully, otherwise ``False``.
@@ -1569,32 +1722,60 @@ def queue_sfx_audio(
Exception: Re-raised after releasing the pipeline if SFX playback setup fails.
"""
try:
- audio = np.asarray(audio, dtype=np.float32)
- audio_len = len(audio) / sample_rate
- engine.log(
- f"Sample rate: {sample_rate} Hz, length: {format_number(audio_len, 2)} seconds"
- )
+ audio = prepare_playback_audio(audio, sample_rate)
+ playback_sample_rate = BASE_SR
+ audio_len = len(audio) / playback_sample_rate
+ if log_length:
+ engine.log(
+ string(
+ "pipeline.sample_rate_length",
+ sample_rate=playback_sample_rate,
+ seconds=format_number(audio_len, 2),
+ )
+ )
- audio = resample_audio(audio, sample_rate)
if keep:
engine.kept_sfx_audio = audio.copy()
source_id = _next_playback_source_id(engine)
- _register_overlay_playback(engine)
+ _register_overlay_playback_state(
+ engine,
+ reset_ready_announcement=reset_ready_announcement,
+ )
_register_playback_source(engine, source_id, kind="sfx", base_gain=volume)
engine.cur_state = "speaking"
# push the smallest possible chunks for responsive stopping
- for chunk in split(audio, BASE_SR, 1):
- _queue_playback_chunk(engine, source_id, chunk, BASE_SR)
+ for chunk in split(audio, playback_sample_rate, 1):
+ _queue_playback_chunk(engine, source_id, chunk, playback_sample_rate)
_queue_playback_done(engine, source_id)
- _set_playback_source_status(engine, source_id, f"Playing {label}")
+ _set_playback_source_status(
+ engine,
+ source_id,
+ string("pipeline.playing_label", label=label),
+ )
return True
except Exception:
engine.playback_done.set()
raise
+def prepare_playback_audio(
+ audio: npt.NDArray[np.float32],
+ sample_rate: int,
+) -> npt.NDArray[np.float32]:
+ """Normalize audio to Celune's shared playback format.
+
+ Args:
+ audio: Decoded mono or stereo audio.
+ sample_rate: Source sample rate for the decoded audio.
+
+ Returns:
+ npt.NDArray[np.float32]: Audio resampled into Celune's playback format.
+ """
+ return resample_audio(np.asarray(audio, dtype=np.float32), sample_rate)
+
+
def play(
engine: Celune, sound_path: str, keep: bool = False, volume: float = 1.0
) -> bool:
@@ -1622,14 +1803,26 @@ def play(
playback_label = sound_path
if not os.path.exists(sound_path):
- engine.log(f"{APP_NAME} cannot find {sound_path}.", "warning")
+ engine.log(
+ string("pipeline.cannot_find_sound", app_name=APP_NAME, path=sound_path),
+ "warning",
+ )
return False
supported_formats = ("wav", "flac", "ogg", "mp3", "aiff")
if not any(sound_path.endswith(audio_format) for audio_format in supported_formats):
- engine.log(f"{APP_NAME} does not support SFX in this format.", "warning")
- engine.log(f"Supported formats: {', '.join(supported_formats)}", "warning")
+ engine.log(
+ string("pipeline.sfx_format_unsupported", app_name=APP_NAME),
+ "warning",
+ )
+ engine.log(
+ string(
+ "pipeline.supported_formats",
+ formats=", ".join(supported_formats),
+ ),
+ "warning",
+ )
return False
audio, sr = sf.read(sound_path, dtype="float32")
@@ -1650,7 +1843,7 @@ def close(engine: Celune) -> None:
Args:
engine: The Celune engine to shut down.
"""
- engine.log("Exiting...")
+ engine.log(string("pipeline.exiting"))
engine._exit_requested = True
with engine.queue_lock:
@@ -1830,9 +2023,21 @@ def play_signal(engine: Celune, signal_type: str) -> bool:
# if a pipeline lock is already held or was not initialized this can cause
# Celune to become deadlocked or it won't have an effect, so please call
# Celune._try_play_signal() instead of calling this method directly
+ if signal_type == "readiness":
+ source_id = _next_playback_source_id(engine)
+ _register_overlay_playback(engine)
+ _register_playback_source(engine, source_id, kind="sfx")
+ _queue_playback_chunk(engine, source_id, signal, BASE_SR)
+ _queue_playback_done(
+ engine,
+ source_id,
+ notify_idle_when_finished=True,
+ )
+ return True
+
if acquire_pipeline(engine, f"play {signal_type} signal"):
release_to_idle = False
- if signal_type != "error" and engine.cur_state != "error":
+ if signal_type not in {"error", "working"} and engine.cur_state != "error":
engine.cur_state = "speaking"
source_id = _next_playback_source_id(engine)
_register_playback_source(engine, source_id, kind="sfx")
@@ -1841,6 +2046,7 @@ def play_signal(engine: Celune, signal_type: str) -> bool:
engine,
source_id,
release_pipeline_when_finished=release_to_idle,
+ notify_idle_when_finished=signal_type == "readiness",
)
release_pipeline(engine, playback_idle=False)
return True
@@ -1884,8 +2090,8 @@ def generation_worker(engine: Celune) -> None:
try:
engine.model_ready.wait()
- if not engine.loaded:
- engine.log("Core engine is not loaded.", "warning")
+ if not engine.loaded and not engine.backend.is_fake:
+ engine.log(string("ui.core_engine_not_loaded"), "warning")
engine.locked = False
if stream_queue is not None:
stream_queue.put(NotAvailableError("model is not ready"))
@@ -1918,7 +2124,7 @@ def generation_worker(engine: Celune) -> None:
chunks = split_text(engine, text)
if not chunks:
engine.progress_callback(0, 1)
- engine.error_callback("Nothing to say")
+ engine.error_callback(string("pipeline.nothing_to_say"))
release_pipeline(engine)
if stream_queue is not None:
stream_queue.put(NotAvailableError("nothing to say"))
@@ -1939,7 +2145,7 @@ def generation_worker(engine: Celune) -> None:
break
if item.normalize:
- engine.status_callback("Normalizing")
+ engine.status_callback(string("status.normalizing"))
engine.progress_callback(None, None)
normalized = engine.normalize(chunk_text)
if normalized is not None:
@@ -2028,7 +2234,13 @@ def generation_worker(engine: Celune) -> None:
if engine.utterance_force_stop.is_set():
break
- speech_timing.mark_first_chunk()
+ first_chunk_time = None
+ if timing is not None:
+ raw_first_chunk_time = timing.get("first_chunk_time")
+ if isinstance(raw_first_chunk_time, float):
+ first_chunk_time = raw_first_chunk_time
+
+ speech_timing.mark_first_chunk(first_chunk_time)
if isinstance(audio_chunk, torch.Tensor):
audio_chunk = audio_chunk.cpu().numpy()
@@ -2044,7 +2256,7 @@ def generation_worker(engine: Celune) -> None:
)
except RuntimeError:
engine.log(
- "Rubber Band is unavailable, speed controls disabled.",
+ string("pipeline.rubber_band_unavailable"),
"warning",
)
engine.can_use_rubberband = False
@@ -2066,8 +2278,7 @@ def generation_worker(engine: Celune) -> None:
break
buffer.append(audio_chunk)
- if save_output:
- full_audio.append(audio_chunk)
+ full_audio.append(audio_chunk)
chunk_dur = len(audio_chunk) / BASE_SR
speech_len += chunk_dur
buffered_speech_len += chunk_dur
@@ -2091,27 +2302,16 @@ def generation_worker(engine: Celune) -> None:
smart_buffer_target_seconds <= 0.0
or buffered_speech_len >= smart_buffer_target_seconds
):
- queued_audio = np.concatenate(buffer)
- _queue_playback_chunk(
+ pushed_audio = _flush_buffered_speech_chunks(
engine,
source_id,
- queued_audio,
- BASE_SR,
- speech_timing if not pushed_audio else None,
+ buffer,
+ speech_timing,
+ pushed_audio,
+ stream_queue,
)
- if stream_queue is not None:
- stream_queue.put(queued_audio.copy())
- buffer = []
buffered_speech_len = 0.0
- if not pushed_audio:
- pushed_audio = True
- _set_playback_source_status(
- engine, source_id, "Speaking"
- )
- engine.cur_state = "speaking"
- engine.queue_avail_callback()
-
if (
not engine.exit_requested
and not engine.utterance_force_stop.is_set()
@@ -2120,8 +2320,7 @@ def generation_worker(engine: Celune) -> None:
and bool(last_timing.get("missing_eos"))
):
engine.log(
- "Generation reached the token limit before completion."
- "Output may be truncated or sound incorrect.",
+ string("pipeline.token_limit_reached"),
"warning",
)
@@ -2143,37 +2342,43 @@ def generation_worker(engine: Celune) -> None:
break
engine.log(
- f"[GEN] {format_number(speech_len, 2)} seconds, "
- f"took {format_number(generation_time, 2)} seconds"
+ string(
+ "pipeline.generation_summary",
+ speech_seconds=format_number(speech_len, 2),
+ generation_seconds=format_number(generation_time, 2),
+ )
)
generation_speed = speech_len / generation_time
- engine.log(f"Speed: x{format_number(generation_speed, 2)}")
+ engine.log(
+ string(
+ "pipeline.generation_speed",
+ speed=format_number(generation_speed, 2),
+ )
+ )
_remember_smart_buffer_speed(engine, generation_speed)
engine.smart_buffer_target_seconds = _smart_buffer_target_seconds(
engine,
speech_len,
generation_time,
)
- engine.log(f"TTFC: {format_number(speech_timing.ttfc_ms(), 1)} ms")
+ engine.log(
+ string(
+ "pipeline.ttfc_ms",
+ milliseconds=format_number(speech_timing.ttfc_ms(), 1),
+ )
+ )
if buffer:
- queued_audio = np.concatenate(buffer)
- _queue_playback_chunk(
+ pushed_audio = _flush_buffered_speech_chunks(
engine,
source_id,
- queued_audio,
- BASE_SR,
- speech_timing if not pushed_audio else None,
+ buffer,
+ speech_timing,
+ pushed_audio,
+ stream_queue,
)
- if stream_queue is not None:
- stream_queue.put(queued_audio.copy())
- if not pushed_audio:
- pushed_audio = True
- _set_playback_source_status(engine, source_id, "Speaking")
- engine.cur_state = "speaking"
- engine.queue_avail_callback()
- engine.log("[GEN] done")
+ engine.log(string("pipeline.generation_done"))
saved_path = None
analysis_audio = None
@@ -2189,22 +2394,30 @@ def generation_worker(engine: Celune) -> None:
full_audio.append(tail)
engine.reverb.reset()
- is_silent, silence_tier = is_silent_utterance(
- np.concatenate(full_audio)
- )
+ is_silent = False
+ silence_tier = 0
+ if full_audio:
+ is_silent, silence_tier = is_silent_utterance(
+ np.concatenate(full_audio)
+ )
if is_silent and silence_tier == 2:
engine.regenerate = True
+ _queue_playback_done(
+ engine,
+ source_id,
+ release_pipeline_when_finished=False,
+ notify_idle_when_finished=False,
+ )
# push recently processed item back so Celune can process it again
engine.text_queue.put(item)
engine.log(
- "Previous utterance was silent, regenerating...", "warning"
+ string("pipeline.silent_regenerating"),
+ "warning",
)
continue
if is_silent and silence_tier == 1:
- engine.log(
- "This utterance may be unexpectedly silent.", "warning"
- )
+ engine.log(string("pipeline.may_be_silent"), "warning")
if save_output and full_audio:
wav = np.concatenate(full_audio)
@@ -2218,13 +2431,17 @@ def generation_worker(engine: Celune) -> None:
first_words = re.sub(r"[^a-zA-Z0-9_]", "", first_words)
if not os.path.exists("outputs"):
- engine.log("Outputs path not found, creating...", "warning")
+ engine.log(
+ string("pipeline.outputs_path_creating"), "warning"
+ )
try:
os.mkdir("outputs")
except OSError as e:
engine.log(
- "Cannot create outputs directory, not saving FLAC output: "
- f"{format_error(e, engine.dev)}",
+ string(
+ "pipeline.outputs_create_failed",
+ error=format_error(e, engine.dev),
+ ),
"warning",
)
@@ -2252,8 +2469,10 @@ def generation_worker(engine: Celune) -> None:
)
except Exception as e:
engine.log(
- "Could not save FLAC output: "
- f"{format_error(e, engine.dev)}",
+ string(
+ "pipeline.flac_save_failed",
+ error=format_error(e, engine.dev),
+ ),
"warning",
)
saved_path = None
@@ -2274,7 +2493,13 @@ def generation_worker(engine: Celune) -> None:
release_pipeline(engine)
break
- engine.log(f"[GEN ERROR] {format_error(e, engine.dev)}", "error")
+ engine.log(
+ string(
+ "pipeline.gen_error",
+ error=format_error(e, engine.dev),
+ ),
+ "error",
+ )
if stream_queue is not None:
stream_queue.put(e)
stream_queue.put(None)
@@ -2282,7 +2507,9 @@ def generation_worker(engine: Celune) -> None:
engine.locked = False
engine.playback_done.set()
engine.progress_callback(0, 1)
- engine.error_callback(f"{APP_NAME} could not generate the input")
+ engine.error_callback(
+ string("pipeline.could_not_generate", app_name=APP_NAME)
+ )
break
@@ -2292,13 +2519,11 @@ def _playback_blocks(
) -> deque[tuple[npt.NDArray[np.float32], Optional[SpeechTiming]]]:
"""Split one queued source chunk into short blocks for the mixer."""
blocks = deque[tuple[npt.NDArray[np.float32], Optional[SpeechTiming]]]()
- pieces = split(chunk.audio, chunk.sample_rate, block_seconds)
- if not pieces:
- pieces = [np.asarray(chunk.audio, dtype=np.float32)]
- for index, piece in enumerate(pieces):
- blocks.append(
- (np.asarray(piece, dtype=np.float32), chunk.timing if index == 0 else None)
- )
+ audio = np.asarray(chunk.audio, dtype=np.float32)
+ frames_per_block = max(1, int(round(chunk.sample_rate * block_seconds)))
+ for start in range(0, len(audio), frames_per_block):
+ piece = np.asarray(audio[start : start + frames_per_block], dtype=np.float32)
+ blocks.append((piece, chunk.timing if start == 0 else None))
return blocks
@@ -2311,23 +2536,43 @@ def _ensure_playback_stream(engine: Celune, sample_rate: int) -> bool:
close_stream(engine, abort=True)
try:
+ output_device_key = (
+ "output_recording_device"
+ if "output_recording_device" in engine.config
+ else "output_device"
+ )
+ output_device = resolve_audio_device(
+ engine.config,
+ output_device_key,
+ "output",
+ )
engine.current_sr = sample_rate
engine.stream = sd.OutputStream(
samplerate=sample_rate,
channels=2,
dtype="float32",
blocksize=0,
+ device=output_device,
)
if engine.stream is None:
raise NotAvailableError("audio stream is not available")
engine.stream.start()
engine.log_dev(f"[PLAY] started stream at {sample_rate} Hz")
return True
+ except ValueError as error:
+ if not getattr(engine, "audio_unavailable", False):
+ engine.log(str(error), "error")
+ engine.error_callback(string("pipeline.no_audio_devices_short"))
+ engine._audio_unavailable = True
+ return False
except sd.PortAudioError:
if not getattr(engine, "audio_unavailable", False):
- engine.log(f"{APP_NAME} could not initialize the audio stream.", "error")
- engine.log("No suitable audio device is available.", "error")
- engine.error_callback("No suitable audio devices")
+ engine.log(
+ string("pipeline.audio_stream_init_failed", app_name=APP_NAME),
+ "error",
+ )
+ engine.log(string("pipeline.no_audio_device"), "error")
+ engine.error_callback(string("pipeline.no_audio_devices_short"))
engine._audio_unavailable = True
return False
@@ -2345,8 +2590,10 @@ def _finalize_playback_idle(
if engine.cur_state == "error":
return
- if not getattr(engine, "locked", False):
- engine.cur_state = "idle"
+ if getattr(engine, "locked", False):
+ return
+
+ engine.cur_state = "idle"
engine.idle_callback()
if random.random() < 0.01:
@@ -2362,7 +2609,7 @@ def _finalize_playback_idle(
if choice == getattr(engine, "_last_flavor", None):
choice = random.choice(flavor_texts)
engine._last_flavor = choice
- engine.log(f"Just type. {choice}")
+ engine.log(string("pipeline.just_type", choice=choice))
else:
if engine.dev and saved_path is not None and analysis_audio is not None:
engine.log_dev("Analyzing...")
@@ -2377,19 +2624,35 @@ def _finalize_playback_idle(
engine.current_voice,
)
- if not getattr(engine, "_ready_announced", False):
- engine.log("Ready to speak.")
+ if (
+ engine.cur_state == "idle"
+ and getattr(engine, "loaded", False)
+ and not getattr(engine, "_ready_announced", False)
+ ):
+ is_vc_mode = False
+ mode_check = getattr(engine, "_is_voice_conversion_mode", None)
+ if callable(mode_check):
+ is_vc_mode = bool(mode_check())
+ else:
+ is_vc_mode = (
+ getattr(engine, "input_mode", "text_to_speech")
+ == "voice_conversion"
+ )
+ if is_vc_mode:
+ engine.log(string("pipeline.ready_to_vc"))
+ else:
+ engine.log(string("pipeline.ready_to_speak"))
engine._ready_announced = True
if torch.cuda.is_available():
avail, total = tuple(v / 1024**3 for v in torch.cuda.mem_get_info(0))
if avail <= total * 0.1:
engine.log(
- f"{APP_NAME} is running out of VRAM. Check the bottom right of {APP_NAME}'s window to learn more.",
+ string("pipeline.vram_low", app_name=APP_NAME),
"warning",
)
engine.log(
- "Please close any memory-resident applications to improve performance.",
+ string("pipeline.close_memory_apps"),
"warning",
)
@@ -2405,7 +2668,6 @@ def _finalize_playback_idle(
queue_playback_done = _queue_playback_done
youtube_sfx_title = _youtube_sfx_title
download_youtube_sfx = _download_youtube_sfx
-remember_visual_context = _remember_visual_context
finalize_playback_idle = _finalize_playback_idle
@@ -2577,7 +2839,7 @@ def drain_pending_items() -> bool:
_update_playback_progress(engine, source_buffers)
except Exception as e:
engine.log(f"[PLAY ERROR] {format_error(e, engine.dev)}", "error")
- engine.error_callback("Playback error")
+ engine.error_callback(string("pipeline.playback_error"))
close_stream(engine, abort=True)
engine._stream = None
engine._current_sr = None
@@ -2608,7 +2870,8 @@ def drain_pending_items() -> bool:
and engine.text_queue.empty(),
)
if (
- not source_buffers
+ marker.notify_idle
+ and not source_buffers
and engine.audio_queue.empty()
and engine.text_queue.empty()
):
@@ -2617,6 +2880,14 @@ def drain_pending_items() -> bool:
saved_path=marker.saved_path,
analysis_audio=marker.analysis_audio,
)
+ elif (
+ not source_buffers
+ and engine.audio_queue.empty()
+ and engine.text_queue.empty()
+ ):
+ engine.playback_done.set()
+ _reset_glow_audio_reactivity(engine)
+ engine.progress_callback(1, 1)
while True:
orphaned = [
@@ -2639,7 +2910,8 @@ def drain_pending_items() -> bool:
and engine.text_queue.empty(),
)
if (
- not source_buffers
+ marker.notify_idle
+ and not source_buffers
and engine.audio_queue.empty()
and engine.text_queue.empty()
):
@@ -2648,6 +2920,14 @@ def drain_pending_items() -> bool:
saved_path=marker.saved_path,
analysis_audio=marker.analysis_audio,
)
+ elif (
+ not source_buffers
+ and engine.audio_queue.empty()
+ and engine.text_queue.empty()
+ ):
+ engine.playback_done.set()
+ _reset_glow_audio_reactivity(engine)
+ engine.progress_callback(1, 1)
if stop_requested and not source_buffers and not source_done:
break
diff --git a/celune/runtime.py b/celune/runtime.py
index c7921ee..e9ac96c 100644
--- a/celune/runtime.py
+++ b/celune/runtime.py
@@ -7,25 +7,33 @@
import torch
+from .i18n import string
from .constants import APP_NAME
+from .backends.tts import CeluneBackend
+from .backends.vc import CeluneVCBackend
from .utils import cuda_architecture, format_number
from . import __codename__, __comment__, __version__
-def log_runtime_banner(log: Callable[[str, str], None], backend_name: str) -> None:
+def log_runtime_banner(
+ log: Callable[[str, str], None], backend: Union[CeluneBackend, CeluneVCBackend]
+) -> None:
"""Log high-level version and environment information.
Args:
log: Logging callback that receives the generated banner lines.
- backend_name: Optional backend name shown in the runtime banner.
+ backend: The backend with which Celune was started.
"""
cuda_version = torch.version.cuda
cuda_line = f", CUDA {cuda_version}" if cuda_version else ""
+ backend_line = (
+ f"on backend {backend.name}, " if not backend.is_fake else "in UI test mode, "
+ )
log(
f"{APP_NAME} {__version__} "
- f"on backend {backend_name}, "
+ f"{backend_line}"
f"Python {platform.python_version()}, "
f"PyTorch {torch.__version__}"
f"{cuda_line}", # NOTE: may concatenate an empty string if CUDA support is not present
@@ -36,7 +44,8 @@ def log_runtime_banner(log: Callable[[str, str], None], backend_name: str) -> No
"info",
)
- log("Environment test...", "info")
+ if not backend.is_fake:
+ log(string("celune.testing_environment"), "info")
def check_supported_backends() -> tuple[str, bool]:
diff --git a/celune/terminal.py b/celune/terminal.py
index fbbe67a..73ed7bb 100644
--- a/celune/terminal.py
+++ b/celune/terminal.py
@@ -4,7 +4,7 @@
import os
import sys
import ctypes
-from typing import IO, Final, Optional, Callable, Any, cast
+from typing import IO, Final, Optional, Callable, cast
from .config import config_value
from .typing.common import ColorMode, Config, JSONSerializable, ResolvedColorMode
@@ -92,7 +92,7 @@ def supports_ansi(stream: Optional[IO[str]] = None) -> bool:
if not callable(windll):
return False
- windll = cast(Callable[..., Any], windll)
+ windll = cast(Callable[..., ctypes.CDLL], windll)
kernel32 = windll("kernel32", use_last_error=True)
handle_id = -12 if output is sys.stderr else -11
diff --git a/celune/typing/__init__.py b/celune/typing/__init__.py
index 85c0f80..b1e1d72 100644
--- a/celune/typing/__init__.py
+++ b/celune/typing/__init__.py
@@ -1,7 +1,7 @@
"""Unified Celune type package with lazy re-exports."""
-from importlib import import_module
from typing import TYPE_CHECKING
+from importlib import import_module
if TYPE_CHECKING:
from .analysis import (
diff --git a/celune/typing/analysis.py b/celune/typing/analysis.py
index 64423d0..d4be90e 100644
--- a/celune/typing/analysis.py
+++ b/celune/typing/analysis.py
@@ -7,9 +7,9 @@
import numpy as np
import numpy.typing as npt
-TextConfigValue = Union[str, dict[str, "TextConfigValue"]]
-TextConfig = dict[str, TextConfigValue]
-EmbeddingPayload = Union[
+type TextConfigValue = Union[str, dict[str, "TextConfigValue"]]
+type TextConfig = dict[str, TextConfigValue]
+type EmbeddingPayload = Union[
torch.Tensor,
npt.NDArray[np.float32],
list[float],
diff --git a/celune/typing/backends.py b/celune/typing/backends.py
index 5ae0ef1..19b7fd8 100644
--- a/celune/typing/backends.py
+++ b/celune/typing/backends.py
@@ -11,7 +11,7 @@ class BackendModel(Protocol):
ModelT = TypeVar("ModelT", bound=BackendModel)
-MiniPromptState = dict[str, dict[str, torch.Tensor]]
+type MiniPromptState = dict[str, dict[str, torch.Tensor]]
class MiniModel(Protocol):
diff --git a/celune/typing/celune.py b/celune/typing/celune.py
index 0ea2042..9eeb232 100644
--- a/celune/typing/celune.py
+++ b/celune/typing/celune.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Iterator
-from typing import TYPE_CHECKING, Any, Callable, Optional, Protocol, Union
+from typing import TYPE_CHECKING, Callable, Optional, Protocol, Union
import torch
from transformers.modeling_utils import PreTrainedModel
@@ -19,13 +19,17 @@
import numpy.typing as npt
import sounddevice as sd
- from ..backends import BackendModel, CeluneBackend
+ from ..backends.tts import BackendModel, CeluneBackend
from ..cevoice import CEVoicePersona
from ..chroma import AudioRGBGlow
from ..constants import PipelineStates
from ..dsp import StreamingPedalboardReverb
from ..extensions.manager import CeluneExtensionManager
from ..persona.impl import PersonaClient
+ from ..backends.vc import CeluneVCBackend
+
+
+type GenerationKwarg = Union[torch.Tensor, int, bool, None]
class SupportsClose(Protocol):
@@ -55,7 +59,7 @@ def unload(self) -> None:
class Generative(Protocol):
"""Protocol for normalization-capable language models."""
- def generate(self, **kwargs: Any) -> torch.Tensor:
+ def generate(self, **kwargs: GenerationKwarg) -> torch.Tensor:
"""Generate token IDs from the provided model inputs.
Args:
@@ -83,7 +87,7 @@ def parameters(self) -> Iterator[torch.nn.Parameter]:
raise NotImplementedError("protocol not defined")
-ReleasableObject = Union[
+type ReleasableObject = Union[
SupportsClose,
SupportsUnload,
PreTrainedModel,
@@ -169,10 +173,15 @@ def __call__(self, progress: Optional[float], total: Optional[float]) -> None:
raise NotImplementedError("protocol not defined")
-ErrorCallback = Callable[[str], None]
-IdleCallback = Callable[[], None]
-QueueAvailableCallback = Callable[[], None]
-VoiceChangedCallback = Callable[[str], None]
+type ErrorCallback = Callable[[str], None]
+type IdleCallback = Callable[[], None]
+type QueueAvailableCallback = Callable[[], None]
+type VoiceChangedCallback = Callable[[str], None]
+type TTSBackendRecipe = Union[str, type["CeluneBackend"]]
+type VCBackendRecipe = Union[str, type["CeluneVCBackend"]]
+type TTSBackendSpec = Union[TTSBackendRecipe, "CeluneBackend"]
+type VCBackendSpec = Union[VCBackendRecipe, "CeluneVCBackend"]
+type CoreBackendSpec = Union[TTSBackendSpec, VCBackendSpec]
class CeluneStateAccessors:
@@ -188,10 +197,16 @@ class CeluneStateAccessors:
change_voice_lock_state_callback: VoiceLockStateCallback
progress_callback: ProgressCallback
config: Config
- _backend_spec: Optional[Union[str, type["CeluneBackend"]]]
+ _backend_spec: Optional[TTSBackendRecipe]
_backend_kwargs: dict[str, JSONSerializable]
backend: "CeluneBackend"
tts_backend: str
+ _vc_backend_spec: Optional[VCBackendRecipe]
+ vc_backend: Optional["CeluneVCBackend"]
+ voice_conversion_backend: str
+ vc_pitch_shift: int
+ vc_f0_condition: bool
+ input_mode: str
chunk_size: int
language: str
dev: bool
@@ -242,10 +257,10 @@ class CeluneStateAccessors:
regenerate: bool
locked: bool
loaded: bool
+ _reload_pending: bool
sleeping: bool
_last_flavor: Optional[str]
_ready_announced: bool
- cur_state: str
is_in_tutorial: bool
extension_manager: Optional["CeluneExtensionManager"]
glow: "AudioRGBGlow"
@@ -265,3 +280,24 @@ class CeluneStateAccessors:
model_lock: "threading.RLock"
audio_unavailable: bool
current_sr: Optional[int]
+
+ @property
+ def cur_state(self) -> str:
+ """Return the current runtime-state label.
+
+ Raises:
+ NotImplementedError: If `NotImplementedError` needs to be raised.
+ """
+ raise NotImplementedError("typing surface only")
+
+ @cur_state.setter
+ def cur_state(self, value: str) -> None:
+ """Store the current runtime-state label.
+
+ Args:
+ value: The new runtime-state label.
+
+ Raises:
+ NotImplementedError: If `NotImplementedError` needs to be raised.
+ """
+ raise NotImplementedError("typing surface only")
diff --git a/celune/typing/cevoice.py b/celune/typing/cevoice.py
index 999112b..e023d37 100644
--- a/celune/typing/cevoice.py
+++ b/celune/typing/cevoice.py
@@ -4,6 +4,6 @@
from .common import JSONSerializable
-ManifestValue = Union[JSONSerializable, "Manifest"]
-Manifest = dict[str, ManifestValue]
-VoiceManifest = dict[str, Manifest]
+type ManifestValue = Union[JSONSerializable, "Manifest"]
+type Manifest = dict[str, ManifestValue]
+type VoiceManifest = dict[str, Manifest]
diff --git a/celune/typing/common.py b/celune/typing/common.py
index d1117b4..33a29ed 100644
--- a/celune/typing/common.py
+++ b/celune/typing/common.py
@@ -3,7 +3,7 @@
from collections.abc import Mapping
from typing import Literal, Optional, Union
-JSONSerializable = Union[
+type JSONSerializable = Union[
None,
bool,
int,
@@ -12,11 +12,11 @@
list["JSONSerializable"],
dict[str, "JSONSerializable"],
]
-JSON = dict[str, JSONSerializable]
-RGB = tuple[int, int, int]
-Config = dict[str, JSONSerializable]
-TerminalConfig = Mapping[str, JSONSerializable]
-ColorMode = Literal["auto", "truecolor", "terminal-default", "ansi", "none"]
-ResolvedColorMode = Literal["truecolor", "terminal-default", "ansi", "none"]
-VramTier = Literal["low", "medium", "high", "xhigh"]
-VideoMetadataScalar = Optional[Union[bool, int, float, str]]
+type JSON = dict[str, JSONSerializable]
+type RGB = tuple[int, int, int]
+type Config = dict[str, JSONSerializable]
+type TerminalConfig = Mapping[str, JSONSerializable]
+type ColorMode = Literal["auto", "truecolor", "terminal-default", "ansi", "none"]
+type ResolvedColorMode = Literal["truecolor", "terminal-default", "ansi", "none"]
+type VramTier = Literal["low", "medium", "high", "xhigh"]
+type VideoMetadataScalar = Optional[Union[bool, int, float, str]]
diff --git a/celune/typing/events.py b/celune/typing/events.py
new file mode 100644
index 0000000..b7872e4
--- /dev/null
+++ b/celune/typing/events.py
@@ -0,0 +1,163 @@
+"""Typed event names, payload protocols, and callback aliases for extensions."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Callable, Literal, Optional, Protocol, Union
+
+from ..dataclasses.events import (
+ AudioEndEvent,
+ AudioStartEvent,
+ CharacterChangedEvent,
+ CharacterLoadedEvent,
+ CharacterUnloadedEvent,
+ ErrorEvent,
+ FatalEvent,
+ GenerationEndEvent,
+ GenerationErrorEvent,
+ GenerationStartEvent,
+ ReadyEvent,
+ ShutdownEvent,
+ StateChangedEvent,
+ VoiceChangedEvent,
+)
+
+
+type EventName = Literal[
+ "ready",
+ "shutdown",
+ "fatal",
+ "error",
+ "voice_changed",
+ "state_changed",
+ "generation_start",
+ "generation_end",
+ "generation_error",
+ "audio_start",
+ "audio_end",
+ "character_changed",
+ "character_loaded",
+ "character_unloaded",
+]
+
+
+class CeluneEventProtocol(Protocol):
+ """Protocol shared by all Celune event payloads."""
+
+ celune: Celune
+
+
+class ErrorEventProtocol(CeluneEventProtocol, Protocol):
+ """Protocol shared by error-like event payloads."""
+
+ error: Exception
+ source: str
+
+
+class StateChangedEventProtocol(CeluneEventProtocol, Protocol):
+ """Protocol for runtime-state transition payloads."""
+
+ old_state: str
+ new_state: str
+
+
+class VoiceChangedEventProtocol(CeluneEventProtocol, Protocol):
+ """Protocol for voice transition payloads."""
+
+ old_voice: str
+ new_voice: str
+
+
+class CharacterLoadedEventProtocol(CeluneEventProtocol, Protocol):
+ """Protocol for CEVOICE bundle activation payloads."""
+
+ character_name: str
+ bundle_path: Optional[str]
+ is_default: bool
+
+
+class CharacterUnloadedEventProtocol(CeluneEventProtocol, Protocol):
+ """Protocol for CEVOICE bundle unload payloads."""
+
+ character_name: str
+ bundle_path: Optional[str]
+
+
+class CharacterChangedEventProtocol(CeluneEventProtocol, Protocol):
+ """Protocol for CEVOICE bundle replacement payloads."""
+
+ old_character: str
+ new_character: str
+ old_bundle_path: Optional[str]
+ new_bundle_path: Optional[str]
+ new_is_default: bool
+
+
+class GenerationEventProtocol(CeluneEventProtocol, Protocol):
+ """Protocol shared by speech-generation payloads."""
+
+ text: str
+ display_text: str
+ save: bool
+
+
+class AudioEventProtocol(CeluneEventProtocol, Protocol):
+ """Protocol shared by audio playback payloads."""
+
+ source_id: int
+ label: str
+ kind: str
+ saved_path: Optional[str]
+
+
+if TYPE_CHECKING:
+ from ..celune import Celune
+
+
+type ReadyEventCallback = Callable[[ReadyEvent], None]
+type ShutdownEventCallback = Callable[[ShutdownEvent], None]
+type FatalEventCallback = Callable[[FatalEvent], None]
+type ErrorEventCallback = Callable[[ErrorEvent], None]
+type VoiceChangedEventCallback = Callable[[VoiceChangedEvent], None]
+type StateChangedEventCallback = Callable[[StateChangedEvent], None]
+type GenerationStartEventCallback = Callable[[GenerationStartEvent], None]
+type GenerationEndEventCallback = Callable[[GenerationEndEvent], None]
+type GenerationErrorEventCallback = Callable[[GenerationErrorEvent], None]
+type AudioStartEventCallback = Callable[[AudioStartEvent], None]
+type AudioEndEventCallback = Callable[[AudioEndEvent], None]
+type CharacterChangedEventCallback = Callable[[CharacterChangedEvent], None]
+type CharacterLoadedEventCallback = Callable[[CharacterLoadedEvent], None]
+type CharacterUnloadedEventCallback = Callable[[CharacterUnloadedEvent], None]
+
+type EventPayload = Union[
+ ReadyEvent,
+ ShutdownEvent,
+ FatalEvent,
+ ErrorEvent,
+ VoiceChangedEvent,
+ StateChangedEvent,
+ GenerationStartEvent,
+ GenerationEndEvent,
+ GenerationErrorEvent,
+ AudioStartEvent,
+ AudioEndEvent,
+ CharacterChangedEvent,
+ CharacterLoadedEvent,
+ CharacterUnloadedEvent,
+]
+
+type EventCallback = Union[
+ ReadyEventCallback,
+ ShutdownEventCallback,
+ FatalEventCallback,
+ ErrorEventCallback,
+ VoiceChangedEventCallback,
+ StateChangedEventCallback,
+ GenerationStartEventCallback,
+ GenerationEndEventCallback,
+ GenerationErrorEventCallback,
+ AudioStartEventCallback,
+ AudioEndEventCallback,
+ CharacterChangedEventCallback,
+ CharacterLoadedEventCallback,
+ CharacterUnloadedEventCallback,
+]
diff --git a/celune/typing/persona.py b/celune/typing/persona.py
index 48f4fa3..a150e18 100644
--- a/celune/typing/persona.py
+++ b/celune/typing/persona.py
@@ -8,10 +8,10 @@
from .common import JSONSerializable, VideoMetadataScalar
-Role = Literal["system", "user", "assistant"]
-VisionInput = Union[JSONSerializable, torch.Tensor, bytes, memoryview]
-ProcessorKwargValue = Union[VideoMetadataScalar, Sequence[VideoMetadataScalar]]
-ModelGenerateKwargValue = Union[torch.Tensor, int, float, bool]
+type Role = Literal["system", "user", "assistant"]
+type VisionInput = Union[JSONSerializable, torch.Tensor, bytes, memoryview]
+type ProcessorKwargValue = Union[VideoMetadataScalar, Sequence[VideoMetadataScalar]]
+type ModelGenerateKwargValue = Union[torch.Tensor, int, float, bool]
class TextContentItem(TypedDict):
@@ -35,15 +35,15 @@ class VideoContentItem(TypedDict):
video: str
-ContentItem = Union[TextContentItem, ImageContentItem, VideoContentItem]
-VideoMetadata = dict[str, VideoMetadataScalar]
-VideoInputWithMetadata = tuple[VisionInput, VideoMetadata]
-VisionProcessorOutput = tuple[
+type ContentItem = Union[TextContentItem, ImageContentItem, VideoContentItem]
+type VideoMetadata = dict[str, VideoMetadataScalar]
+type VideoInputWithMetadata = tuple[VisionInput, VideoMetadata]
+type VisionProcessorOutput = tuple[
Optional[list[VisionInput]],
Optional[list[VideoInputWithMetadata]],
dict[str, ProcessorKwargValue],
]
-MessageContent = Union[str, list[ContentItem]]
+type MessageContent = Union[str, list[ContentItem]]
class ChatMessagePayload(TypedDict):
@@ -53,7 +53,7 @@ class ChatMessagePayload(TypedDict):
content: MessageContent
-JSONDict = ChatMessagePayload
+type JSONDict = ChatMessagePayload
class ChatTemplateRenderer(Protocol):
diff --git a/celune/typing/pipeline.py b/celune/typing/pipeline.py
index 680f2c2..eca39ed 100644
--- a/celune/typing/pipeline.py
+++ b/celune/typing/pipeline.py
@@ -7,10 +7,20 @@
import numpy.typing as npt
from ..constants import PipelineStates
-from ..dataclasses.pipeline import PlaybackChunk, PlaybackSourceDone, SpeechRequest
+from ..dataclasses.pipeline import (
+ AudioOutput,
+ AudioInputRequest,
+ PlaybackChunk,
+ PlaybackSourceDone,
+ SpeechRequest,
+ VoiceConversionRequest,
+)
-SpeechStreamItem = Optional[Union[npt.NDArray[np.float32], Exception]]
-SpeechStreamQueue = queue.Queue[SpeechStreamItem]
-TextQueueItem = Union[SpeechRequest, PipelineStates]
-AudioChunk = PlaybackChunk
-AudioQueueItem = Union[PlaybackChunk, PlaybackSourceDone, PipelineStates]
+type SpeechStreamItem = Optional[Union[npt.NDArray[np.float32], Exception]]
+type SpeechStreamQueue = queue.Queue[SpeechStreamItem]
+type TextQueueItem = Union[SpeechRequest, PipelineStates]
+type AudioInputItem = AudioInputRequest
+type AudioOutputItem = AudioOutput
+type VoiceConversionInputItem = VoiceConversionRequest
+type AudioChunk = PlaybackChunk
+type AudioQueueItem = Union[PlaybackChunk, PlaybackSourceDone, PipelineStates]
diff --git a/celune/ui/app.py b/celune/ui/app.py
index cf70873..9a249f0 100644
--- a/celune/ui/app.py
+++ b/celune/ui/app.py
@@ -6,6 +6,7 @@
import time
import types
import shlex
+import queue as queue_module
import signal
import ctypes
import logging
@@ -13,32 +14,38 @@
import itertools
import threading
import contextlib
-from dataclasses import dataclass, field
from pathlib import Path
from collections.abc import Iterator
+from dataclasses import dataclass, field
from typing import Optional, Callable, Union, TextIO, cast
+import numpy as np
+import numpy.typing as npt
+import sounddevice as sd
import yaml
from rich.text import Text
from textual.color import Color
from textual.timer import Timer
+from textual.theme import Theme
from textual import work, events
from textual.widget import Widget
-from textual.theme import Theme
-from textual.app import ScreenStackError
from textual.css.types import EdgeStyle
+from textual.app import ScreenStackError
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Label, RichLog, TextArea, Button, ProgressBar
from .. import colors
from ..celune import Celune
-from ..constants import APP_NAME, SIGTSTP
from ..cevoice import default_loader
+from ..config import resolve_audio_device_with_info
from . import resources as ui_resources
+from .resources import FOOTER_ROTATE_SECONDS
from .theme import CELUNE_CSS, severity_color
-from .terminal import LogRedirect, UILogHandler
+from .terminal import LogRedirect, UILogHandler, is_celune_log_record
from ..paths import config_path, main_window_log_path
+from ..constants import APP_NAME, SIGTSTP, CRASH_LINES
+from ..i18n import string
from .commands import process_command as process_ui_command
from ..persona.impl import (
persona_talkback_enabled,
@@ -55,6 +62,48 @@
discard,
)
+_VC_PITCH_SHIFT_MIN = -12
+_VC_PITCH_SHIFT_MAX = 12
+_VC_LIVE_STREAM_CHUNK_SECONDS = 0.32
+_VC_LIVE_STREAM_OVERLAP_SECONDS = 0.12
+_VC_FEEDBACK_RMS_MIN_PREVIOUS = 0.05
+_VC_FEEDBACK_RMS_MIN_CURRENT = 0.18
+_VC_FEEDBACK_RMS_RISE_RATIO = 2.0
+_VC_FEEDBACK_RMS_RISE_DELTA = 0.08
+_RUNTIME_LOG_REDIRECT_FILTER_MESSAGES = frozenset(
+ {
+ "`torch_dtype` is deprecated! Use `dtype` instead!",
+ "Skipped loading some keys due to shape mismatch:",
+ "cfm loaded",
+ "length_regulator loaded",
+ "Removing weight norm...",
+ "Loading weights from",
+ "min value is",
+ "max value is",
+ "it/s]",
+ "s/it]",
+ }
+)
+_AudioDeviceScalar = Union[bool, int, float, str]
+_VCAudioCallback = Callable[
+ [
+ npt.NDArray[np.float32],
+ int,
+ Optional[tuple[float, float, float]],
+ Optional[sd.CallbackFlags],
+ ],
+ None,
+]
+
+
+def _device_scalar_int(value: Optional[_AudioDeviceScalar], default: int) -> int:
+ """Return one audio-device metadata value as an integer when possible."""
+ if isinstance(value, bool):
+ return default
+ if isinstance(value, (int, float, str)):
+ return int(value)
+ return default
+
@dataclass
class CeluneUIWidgetState:
@@ -63,6 +112,8 @@ class CeluneUIWidgetState:
logs: Optional[RichLog] = None
input_box: Optional[TextArea] = None
style_button: Optional[Button] = None
+ vc_mode_button: Optional[Button] = None
+ vc_pitch_button: Optional[Button] = None
status: Optional[Label] = None
resources: Optional[Label] = None
progress_bar: Optional[ProgressBar] = None
@@ -112,12 +163,18 @@ class CeluneUILogCaptureState:
log_stdout: Optional[LogRedirect] = None
log_stderr: Optional[LogRedirect] = None
runtime_log_capture_enabled: bool = False
- runtime_redirect_loggers: Optional[dict[str, logging.Logger]] = None
- runtime_redirect_handlers: Optional[dict[str, UILogHandler]] = None
- runtime_redirect_original_handlers: Optional[dict[str, list[logging.Handler]]] = (
- None
- )
- runtime_redirect_original_propagate: Optional[dict[str, bool]] = None
+ runtime_redirect_handler: Optional[UILogHandler] = None
+ runtime_redirect_original_call_handlers: Optional[
+ Callable[[logging.Logger, logging.LogRecord], None]
+ ] = None
+ runtime_redirect_original_last_resort: Optional[logging.Handler] = None
+ runtime_redirect_original_raise_exceptions: Optional[bool] = None
+ original_dunder_stdout: Optional[TextIO] = None
+ original_dunder_stderr: Optional[TextIO] = None
+ stderr_pipe_read_fd: Optional[int] = None
+ stderr_pipe_write_fd: Optional[int] = None
+ stderr_original_fd_dup: Optional[int] = None
+ stderr_forward_thread: Optional[threading.Thread] = None
warnings_capture_enabled: bool = False
log_file_path: Path = field(default_factory=Path)
log_file_initialized: bool = False
@@ -130,6 +187,20 @@ class CeluneUIInteractionState:
border_pulse_tokens: dict[int, int] = field(default_factory=dict)
border_pulse_widgets: dict[int, Widget] = field(default_factory=dict)
tutorial_timers: list[Timer] = field(default_factory=list)
+ vc_recording_buffered_frames: int = 0
+ vc_recording_chunks: list[npt.NDArray[np.float32]] = field(default_factory=list)
+ vc_recording_captured_frames: int = 0
+ vc_recording_feedback_detected: bool = False
+ vc_recording_label: str = ""
+ vc_recording_lock: threading.Lock = field(default_factory=threading.Lock)
+ vc_recording_previous_rms: float = 0.0
+ vc_recording_sample_rate: int = 0
+ vc_recording_submission_queue: Optional[
+ queue_module.Queue[Optional[tuple[npt.NDArray[np.float32], int, str, bool]]]
+ ] = None
+ vc_recording_stream: Optional[sd.InputStream] = None
+ vc_recording_stop_thread: Optional[threading.Thread] = None
+ vc_recording_worker: Optional[threading.Thread] = None
sleep_timer: Optional[Timer] = None
tutorial_token: int = 0
tutorial_active: bool = False
@@ -191,6 +262,8 @@ def __init__(self) -> None:
logs = _forward_ui_property("_widgets", "logs")
input_box = _forward_ui_property("_widgets", "input_box")
style_button = _forward_ui_property("_widgets", "style_button")
+ vc_mode_button = _forward_ui_property("_widgets", "vc_mode_button")
+ vc_pitch_button = _forward_ui_property("_widgets", "vc_pitch_button")
status = _forward_ui_property("_widgets", "status")
resources = _forward_ui_property("_widgets", "resources")
progress_bar = _forward_ui_property("_widgets", "progress_bar")
@@ -233,17 +306,35 @@ def __init__(self) -> None:
_runtime_log_capture_enabled = _forward_ui_property(
"_log_capture_state", "runtime_log_capture_enabled"
)
- _runtime_redirect_loggers = _forward_ui_property(
- "_log_capture_state", "runtime_redirect_loggers"
+ _runtime_redirect_handler = _forward_ui_property(
+ "_log_capture_state", "runtime_redirect_handler"
+ )
+ _runtime_redirect_original_call_handlers = _forward_ui_property(
+ "_log_capture_state", "runtime_redirect_original_call_handlers"
+ )
+ _runtime_redirect_original_last_resort = _forward_ui_property(
+ "_log_capture_state", "runtime_redirect_original_last_resort"
+ )
+ _runtime_redirect_original_raise_exceptions = _forward_ui_property(
+ "_log_capture_state", "runtime_redirect_original_raise_exceptions"
)
- _runtime_redirect_handlers = _forward_ui_property(
- "_log_capture_state", "runtime_redirect_handlers"
+ _original_dunder_stdout = _forward_ui_property(
+ "_log_capture_state", "original_dunder_stdout"
)
- _runtime_redirect_original_handlers = _forward_ui_property(
- "_log_capture_state", "runtime_redirect_original_handlers"
+ _original_dunder_stderr = _forward_ui_property(
+ "_log_capture_state", "original_dunder_stderr"
)
- _runtime_redirect_original_propagate = _forward_ui_property(
- "_log_capture_state", "runtime_redirect_original_propagate"
+ _stderr_pipe_read_fd = _forward_ui_property(
+ "_log_capture_state", "stderr_pipe_read_fd"
+ )
+ _stderr_pipe_write_fd = _forward_ui_property(
+ "_log_capture_state", "stderr_pipe_write_fd"
+ )
+ _stderr_original_fd_dup = _forward_ui_property(
+ "_log_capture_state", "stderr_original_fd_dup"
+ )
+ _stderr_forward_thread = _forward_ui_property(
+ "_log_capture_state", "stderr_forward_thread"
)
_warnings_capture_enabled = _forward_ui_property(
"_log_capture_state", "warnings_capture_enabled"
@@ -260,6 +351,40 @@ def __init__(self) -> None:
"_interaction_state", "border_pulse_widgets"
)
_tutorial_timers = _forward_ui_property("_interaction_state", "tutorial_timers")
+ _vc_recording_chunks = _forward_ui_property(
+ "_interaction_state", "vc_recording_chunks"
+ )
+ _vc_recording_buffered_frames = _forward_ui_property(
+ "_interaction_state", "vc_recording_buffered_frames"
+ )
+ _vc_recording_label = _forward_ui_property(
+ "_interaction_state", "vc_recording_label"
+ )
+ _vc_recording_captured_frames = _forward_ui_property(
+ "_interaction_state", "vc_recording_captured_frames"
+ )
+ _vc_recording_feedback_detected = _forward_ui_property(
+ "_interaction_state", "vc_recording_feedback_detected"
+ )
+ _vc_recording_lock = _forward_ui_property("_interaction_state", "vc_recording_lock")
+ _vc_recording_previous_rms = _forward_ui_property(
+ "_interaction_state", "vc_recording_previous_rms"
+ )
+ _vc_recording_sample_rate = _forward_ui_property(
+ "_interaction_state", "vc_recording_sample_rate"
+ )
+ _vc_recording_submission_queue = _forward_ui_property(
+ "_interaction_state", "vc_recording_submission_queue"
+ )
+ _vc_recording_stream = _forward_ui_property(
+ "_interaction_state", "vc_recording_stream"
+ )
+ _vc_recording_stop_thread = _forward_ui_property(
+ "_interaction_state", "vc_recording_stop_thread"
+ )
+ _vc_recording_worker = _forward_ui_property(
+ "_interaction_state", "vc_recording_worker"
+ )
_sleep_timer = _forward_ui_property("_interaction_state", "sleep_timer")
_tutorial_token = _forward_ui_property("_interaction_state", "tutorial_token")
_tutorial_active = _forward_ui_property("_interaction_state", "tutorial_active")
@@ -361,7 +486,7 @@ def _clear_border_pulses(self) -> None:
def _refresh_theme_text(self) -> None:
"""Refresh widgets after a runtime theme change."""
- def repaint(widget: object) -> None:
+ def repaint(widget: Widget) -> None:
refresh = getattr(widget, "refresh", None)
if refresh is None:
return
@@ -440,6 +565,29 @@ def wrapped_fatal() -> None:
glow.fatal = wrapped_fatal
setattr(self.celune, "_ui_fatal_glow_wrapped", True)
+ def _bind_runtime_callbacks(self) -> None:
+ """Bind one attached Celune instance back into this UI."""
+ if self.celune is None:
+ return
+
+ self.celune.log_callback = self.tts_log
+ self.celune.status_callback = self.safe_status
+ self.celune.error_callback = self.error
+ self.celune.idle_callback = self.tts_idle
+ self.celune.queue_avail_callback = self.tts_queue_avail
+ self.celune.voice_changed_callback = self.tts_voice_changed
+ self.celune.change_input_state_callback = self.change_input_state
+ self.celune.change_voice_lock_state_callback = self.change_voice_lock_state
+ self.celune.progress_callback = self.safe_progress
+
+ def _is_ui_test_mode(self) -> bool:
+ """Return whether the attached runtime is the interactive fake-backend UI test mode."""
+ if self.celune is None:
+ return False
+
+ backend = getattr(self.celune, "backend", None)
+ return bool(getattr(backend, "is_fake", False)) and "pytest" not in sys.modules
+
def _refresh_status(self) -> None:
"""Refresh the status color for the active theme."""
if self.status is None:
@@ -549,8 +697,14 @@ def compose(self) -> ComposeResult:
id="progress", show_percentage=False, show_eta=False, total=1
)
with Horizontal(id="controls"):
- yield TextArea(id="input", placeholder="Please wait")
- yield Button("No Voice Set", id="style", disabled=True)
+ yield TextArea(id="input", placeholder=string("ui.wait_placeholder"))
+ yield Button(string("ui.no_voice_set"), id="style", disabled=True)
+ yield Button(string("ui.vc_mode_talk"), id="vc-mode", disabled=True)
+ yield Button(
+ string("ui.vc_pitch_button", value="+0"),
+ id="vc-pitch",
+ disabled=True,
+ )
with Horizontal(id="bottom"):
yield Label("", id="status")
yield Label("", id="resources")
@@ -596,6 +750,7 @@ def on_mount(self) -> None:
)
self._ensure_themes_registered()
+ self._bind_runtime_callbacks()
self._wrap_runtime_fatal_glow()
if is_april_fools() and os.getenv("CELUNE_DISABLE_APRIL_FOOLS") not in {
@@ -615,7 +770,7 @@ def on_mount(self) -> None:
self.active_theme_name = "celune_light"
else:
self.active_theme_name = "celune"
- self.safe_log("Invalid theme, defaulting to dark", "warning")
+ self.safe_log(string("ui.invalid_theme_defaulting_dark"), "warning")
self.theme = self.active_theme_name
@@ -624,22 +779,26 @@ def on_mount(self) -> None:
self.status = self.query_one("#status", Label)
self.resources = self.query_one("#resources", Label)
self.style_button = self.query_one("#style", Button)
+ self.vc_mode_button = self.query_one("#vc-mode", Button)
+ self.vc_pitch_button = self.query_one("#vc-pitch", Button)
self.progress_bar = self.query_one("#progress", ProgressBar)
self.header = self.query_one("#header", Label)
self.header_lines = tuple(cast(Label, widget) for widget in self.query(".line"))
self.set_focus(None)
self._refresh_status()
+ self.refresh_vc_controls()
self._refresh_theme_text()
self._refresh_logs()
- self._install_runtime_log_redirects()
+ if not self.celune.backend.is_fake or "pytest" in sys.modules:
+ self._enable_runtime_log_capture()
ui_resources.prime_usage()
- self.set_interval(2.06, self.advance_resources)
+ self.set_interval(FOOTER_ROTATE_SECONDS, self.advance_resources)
self._status_marquee_timer = self.set_interval(
0.18, self._advance_status_marquee
)
self.call_after_refresh(self.start_background_init)
- self.safe_status("Initializing")
+ self.safe_status(string("status.initializing"))
self.update_resources()
def update_resources(self) -> None:
@@ -652,10 +811,7 @@ def update() -> None:
text = pages[self._resource_page % len(pages)]
if supports_ansi() and self.celune.cur_state == "error":
- if self._resource_page % 2:
- self._old_stdout.write(f"\x1b]2;{APP_NAME}\x07")
- else:
- self._old_stdout.write("\x1b]2;has crashed\x07")
+ self._write_terminal_escape(f"\x1b]2;{next(CRASH_LINES)}\x07")
self.resources.update(indent(text, spaces=2, direction="right"))
@@ -666,78 +822,211 @@ def _enable_runtime_log_capture(self) -> None:
if self._runtime_log_capture_enabled:
return
- self._old_stdout = sys.stdout
- self._old_stderr = sys.stderr
self._log_stdout = LogRedirect(
write_callback=self.safe_log,
default_severity="info",
stdout=self._old_stdout,
stderr=self._old_stderr,
- filter_messages={"`torch_dtype` is deprecated! Use `dtype` instead!"},
+ filter_messages=_RUNTIME_LOG_REDIRECT_FILTER_MESSAGES,
)
self._log_stderr = LogRedirect(
write_callback=self.safe_log,
default_severity="warning",
stdout=self._old_stdout,
stderr=self._old_stderr,
- filter_messages={"`torch_dtype` is deprecated! Use `dtype` instead!"},
+ filter_messages=_RUNTIME_LOG_REDIRECT_FILTER_MESSAGES,
)
sys.stdout = self._log_stdout
sys.stderr = self._log_stderr
+ self._redirect_dunder_stdio()
self._install_runtime_log_redirects()
+ self._install_low_level_stderr_capture()
self._runtime_log_capture_enabled = True
+ def _write_terminal_escape(self, escape: str) -> None:
+ """Write one ANSI escape sequence to the real terminal when available."""
+ if self._log_stdout is not None:
+ self._log_stdout.ansi(escape)
+ return
+
+ if self._old_stdout is not None:
+ self._old_stdout.write(escape)
+ self._old_stdout.flush()
+
def _install_runtime_log_redirects(self) -> None:
- """Route known runtime logger output into Celune's UI log widget."""
- if self._runtime_redirect_loggers is not None:
+ """Route non-Celune Python logging output into Celune's UI log widget."""
+ if self._runtime_redirect_handler is not None:
return
- self._runtime_redirect_loggers = {}
- self._runtime_redirect_handlers = {}
- self._runtime_redirect_original_handlers = {}
- self._runtime_redirect_original_propagate = {}
-
- for logger_name in ("torch.utils.flop_counter", "py.warnings"):
- logger = logging.getLogger(logger_name)
- handler = UILogHandler(self.safe_log)
- self._runtime_redirect_loggers[logger_name] = logger
- self._runtime_redirect_handlers[logger_name] = handler
- self._runtime_redirect_original_handlers[logger_name] = list(
- logger.handlers
- )
- self._runtime_redirect_original_propagate[logger_name] = logger.propagate
- logger.handlers = [handler]
- logger.propagate = False
+ handler = UILogHandler(
+ self.safe_log,
+ filter_messages=_RUNTIME_LOG_REDIRECT_FILTER_MESSAGES,
+ )
+ original_call_handlers = logging.Logger.callHandlers
+
+ def call_handlers(self: logging.Logger, record: logging.LogRecord) -> None:
+ if is_celune_log_record(record):
+ original_call_handlers(self, record)
+ return
+
+ handler.handle(record)
+
+ self._runtime_redirect_handler = handler
+ self._runtime_redirect_original_call_handlers = original_call_handlers
+ self._runtime_redirect_original_last_resort = logging.lastResort
+ self._runtime_redirect_original_raise_exceptions = logging.raiseExceptions
+ logging.Logger.callHandlers = call_handlers
+ logging.lastResort = None
+ logging.raiseExceptions = False
logging.captureWarnings(True)
self._warnings_capture_enabled = True
- def _remove_runtime_log_redirects(self) -> None:
- """Restore Python logger output handlers replaced by the UI."""
- loggers = self._runtime_redirect_loggers
- handlers = self._runtime_redirect_handlers
- original_handlers = self._runtime_redirect_original_handlers
- original_propagate = self._runtime_redirect_original_propagate
+ def _redirect_dunder_stdio(self) -> None:
+ """Redirect ``sys.__stdout__`` and ``sys.__stderr__`` when possible."""
+ if self._original_dunder_stdout is None:
+ self._original_dunder_stdout = sys.__stdout__
+ if self._original_dunder_stderr is None:
+ self._original_dunder_stderr = sys.__stderr__
+
+ if self._log_stdout is not None:
+ sys.__stdout__ = self._log_stdout
+ if self._log_stderr is not None:
+ sys.__stderr__ = self._log_stderr
+
+ def _restore_dunder_stdio(self) -> None:
+ """Restore ``sys.__stdout__`` and ``sys.__stderr__`` after capture ends."""
+ if self._original_dunder_stdout is not None:
+ sys.__stdout__ = self._original_dunder_stdout
+ if self._original_dunder_stderr is not None:
+ sys.__stderr__ = self._original_dunder_stderr
+
+ self._original_dunder_stdout = None
+ self._original_dunder_stderr = None
+
+ def _install_low_level_stderr_capture(self) -> None:
+ """Capture writes that bypass Python and go straight to stderr."""
+ if self._stderr_forward_thread is not None:
+ return
+
+ stderr_stream = self._old_stderr
+ if stderr_stream is None or not hasattr(stderr_stream, "fileno"):
+ return
+
+ original_fd_dup: Optional[int] = None
+ pipe_read_fd: Optional[int] = None
+ pipe_write_fd: Optional[int] = None
+
+ try:
+ stderr_fd = stderr_stream.fileno()
+ if not isinstance(stderr_fd, int):
+ return
+ original_fd_dup = os.dup(stderr_fd)
+ pipe_read_fd, pipe_write_fd = os.pipe()
+ os.dup2(pipe_write_fd, stderr_fd)
+ except (AttributeError, OSError, TypeError, ValueError):
+ with contextlib.suppress(OSError):
+ if original_fd_dup is not None:
+ os.close(original_fd_dup)
+ with contextlib.suppress(OSError):
+ if pipe_read_fd is not None:
+ os.close(pipe_read_fd)
+ with contextlib.suppress(OSError):
+ if pipe_write_fd is not None:
+ os.close(pipe_write_fd)
+ return
+
+ self._stderr_original_fd_dup = original_fd_dup
+ self._stderr_pipe_read_fd = pipe_read_fd
+ self._stderr_pipe_write_fd = pipe_write_fd
+
+ forward_thread = threading.Thread(
+ target=self._forward_low_level_stderr,
+ name="celune-stderr-capture",
+ daemon=True,
+ )
+ self._stderr_forward_thread = forward_thread
+ forward_thread.start()
+
+ def _forward_low_level_stderr(self) -> None:
+ """Forward low-level stderr bytes back to the terminal and UI log."""
+ read_fd = self._stderr_pipe_read_fd
+ original_fd_dup = self._stderr_original_fd_dup
+ redirect = self._log_stderr
+
+ if read_fd is None or original_fd_dup is None or redirect is None:
+ return
+
+ encoding = getattr(self._old_stderr, "encoding", None) or "utf-8"
+ errors = getattr(self._old_stderr, "errors", None) or "replace"
+
+ while True:
+ try:
+ payload = os.read(read_fd, 4096)
+ except OSError:
+ break
+
+ if not payload:
+ break
+
+ redirect.write(payload.decode(encoding, errors=errors))
+
+ redirect.flush()
+
+ def _remove_low_level_stderr_capture(self) -> None:
+ """Restore stderr after low-level capture was installed."""
+ stderr_stream = self._old_stderr
+ original_fd_dup = self._stderr_original_fd_dup
+ pipe_write_fd = self._stderr_pipe_write_fd
+ pipe_read_fd = self._stderr_pipe_read_fd
+
if (
- loggers is not None
- and handlers is not None
- and original_handlers is not None
- and original_propagate is not None
+ stderr_stream is not None
+ and hasattr(stderr_stream, "fileno")
+ and original_fd_dup is not None
):
- for logger_name, logger in loggers.items():
- logger.handlers = original_handlers[logger_name]
- logger.propagate = original_propagate[logger_name]
- handlers[logger_name].close()
+ with contextlib.suppress(OSError, ValueError):
+ stderr_stream.flush()
+ with contextlib.suppress(OSError, ValueError):
+ os.dup2(original_fd_dup, stderr_stream.fileno())
+
+ if pipe_write_fd is not None:
+ with contextlib.suppress(OSError):
+ os.close(pipe_write_fd)
+ if pipe_read_fd is not None:
+ with contextlib.suppress(OSError):
+ os.close(pipe_read_fd)
+ if original_fd_dup is not None:
+ with contextlib.suppress(OSError):
+ os.close(original_fd_dup)
+
+ self._stderr_pipe_read_fd = None
+ self._stderr_pipe_write_fd = None
+ self._stderr_original_fd_dup = None
+ self._stderr_forward_thread = None
+
+ def _remove_runtime_log_redirects(self) -> None:
+ """Restore Python logging dispatch after UI shutdown."""
+ handler = self._runtime_redirect_handler
+ original_call_handlers = self._runtime_redirect_original_call_handlers
+ if handler is not None and original_call_handlers is not None:
+ logging.Logger.callHandlers = original_call_handlers
+ logging.lastResort = self._runtime_redirect_original_last_resort
+ if self._runtime_redirect_original_raise_exceptions is not None:
+ logging.raiseExceptions = (
+ self._runtime_redirect_original_raise_exceptions
+ )
+ handler.close()
if self._warnings_capture_enabled:
logging.captureWarnings(False)
self._warnings_capture_enabled = False
- self._runtime_redirect_loggers = None
- self._runtime_redirect_handlers = None
- self._runtime_redirect_original_handlers = None
- self._runtime_redirect_original_propagate = None
+ self._runtime_redirect_handler = None
+ self._runtime_redirect_original_call_handlers = None
+ self._runtime_redirect_original_last_resort = None
+ self._runtime_redirect_original_raise_exceptions = None
def _disable_runtime_log_capture(self) -> None:
"""Restore global stdio once the UI is shutting down."""
@@ -746,7 +1035,9 @@ def _disable_runtime_log_capture(self) -> None:
if self._log_stderr is not None:
self._log_stderr.flush()
+ self._remove_low_level_stderr_capture()
self._remove_runtime_log_redirects()
+ self._restore_dunder_stdio()
sys.stdout = self._old_stdout
sys.stderr = self._old_stderr
@@ -802,10 +1093,10 @@ def _enter_sleep_mode(self) -> None:
if self.celune.enter_sleep_mode():
self.safe_log(
- f"{APP_NAME} is currently sleeping. Type anything to wake up.",
+ string("ui.sleeping_log", app_name=APP_NAME),
"sleeping",
)
- self.safe_status("Sleeping", "sleeping")
+ self.safe_status(string("ui.sleeping_status"), "sleeping")
self.change_voice_lock_state(locked=True)
@work(thread=True, exclusive=True)
@@ -816,7 +1107,7 @@ def wake_from_sleep(self) -> None:
self._schedule_sleep_timer()
finally:
if self.celune.sleeping:
- self.safe_status("Sleeping", "sleeping")
+ self.safe_status(string("ui.sleeping_status"), "sleeping")
def start_background_init(self) -> None:
"""Run the initialization function."""
@@ -829,9 +1120,17 @@ def load_tts(self) -> None:
if self.celune.load():
self.celune_styles = self.celune.voices
if not self.celune_styles:
+ if self._is_ui_test_mode():
+ if not self.celune.use_normalization:
+ self.safe_progress(1, 1)
+ self.change_input_state(locked=True)
+ self.change_voice_lock_state(locked=True)
+ self.safe_status(string("ui.test_mode_active"))
+ return
+
self.change_input_state(locked=True)
self.change_voice_lock_state(locked=True)
- self.error(f"{APP_NAME} could not start")
+ self.error(string("ui.app_could_not_start", app_name=APP_NAME))
self.cur_state = "error"
return
self.celune_voices = itertools.cycle(self.celune_styles)
@@ -842,7 +1141,7 @@ def load_tts(self) -> None:
else:
self.style_index = 0
self.celune_ready = True
- self.safe_status("Idle")
+ self.safe_status(string("ui.idle_status"))
self.tts_voice_changed(
self.celune.current_voice or self.celune.voices[0]
)
@@ -850,25 +1149,32 @@ def load_tts(self) -> None:
self.safe_progress(1, 1)
self.change_input_state(locked=False)
self.change_voice_lock_state(locked=len(self.celune.voices) < 2)
- self.call_from_thread(self._enable_runtime_log_capture)
- self.safe_log(
- f"New to {APP_NAME}? Type /tutorial to begin the tutorial."
- )
+ self.safe_log(string("ui.tutorial_prompt", app_name=APP_NAME))
self._schedule_sleep_timer()
+ if supports_ansi(self._old_stdout):
+ self.call_from_thread(
+ lambda: self._write_terminal_escape(f"\x1b]2;{APP_NAME}\x07")
+ )
else:
self.cur_state = "error"
self.change_input_state(locked=True)
self.change_voice_lock_state(locked=True)
- self.error(f"{APP_NAME} could not start")
+ self.error(string("ui.app_could_not_start", app_name=APP_NAME))
except Exception as e:
self.cur_state = "error"
- self.safe_log(f"[INIT ERROR] {format_error(e, self.celune.dev)}", "error")
+ self.safe_log(
+ string(
+ "ui.init_error",
+ error=format_error(e, self.celune.dev),
+ ),
+ "error",
+ )
self.celune.glow.fatal()
if not self.celune.try_play_signal("error"):
- self.safe_log_dev("Could not play the error signal.", "warning")
+ self.safe_log_dev(string("ui.error_signal_unavailable"), "warning")
self.change_input_state(locked=True)
self.change_voice_lock_state(locked=True)
- self.error(f"{APP_NAME} could not start")
+ self.error(string("ui.app_could_not_start", app_name=APP_NAME))
def safe_progress(
self, progress: Optional[float], total: Optional[float] = None
@@ -917,7 +1223,7 @@ def pulse_border(self, target: Union[str, Widget]) -> None:
target: Widget or Textual selector for the target widget.
"""
if threading.current_thread() is not threading.main_thread():
- self.call_from_thread(self.pulse_border, target)
+ self.call_from_thread(lambda: self.pulse_border(target))
return
duration = 2.06
@@ -1030,15 +1336,18 @@ def update() -> None:
def _normal_input_placeholder(self) -> str:
"""Return the unlocked input placeholder without blocking the UI."""
+ if self._is_voice_conversion_mode():
+ return string("ui.voice_changer_placeholder")
+
if (
self._persona_loaded()
and self._persona_available
and persona_enabled(self.celune.config)
and persona_talkback_enabled(self.celune.config)
):
- return "Say something..."
+ return string("ui.say_placeholder")
- return "Enter text to speak here"
+ return string("ui.input_placeholder")
def _persona_loaded(self) -> bool:
"""Return whether the attached Celune instance currently has Persona."""
@@ -1081,9 +1390,12 @@ def change_input_state(self, locked: bool) -> None:
def update() -> None:
self._input_locked = locked
self.input_box.placeholder = (
- "Please wait" if locked else self._normal_input_placeholder()
+ string("ui.wait_placeholder")
+ if locked
+ else self._normal_input_placeholder()
)
self.style_button.disabled = locked
+ self.refresh_vc_controls()
self.update_resources()
self._run_on_ui_thread(update)
@@ -1107,8 +1419,9 @@ def safe_status(self, msg: str, severity: str = "info") -> None:
)
severity = "info"
- if severity != "error":
- self._fatal_error_active = False
+ if self._fatal_error_active and severity != "error":
+ return
+
self.status_severity = severity
def update() -> None:
@@ -1143,7 +1456,7 @@ def safe_log(self, msg: str, severity: str = "info") -> None:
if threading.current_thread() is threading.main_thread():
self.logs.write(entry)
else:
- self.call_from_thread(self.logs.write, entry)
+ self.call_from_thread(lambda: self.logs.write(entry))
def safe_log_dev(self, msg: str, severity: str = "info") -> None:
"""Log a message.
@@ -1155,6 +1468,649 @@ def safe_log_dev(self, msg: str, severity: str = "info") -> None:
if self.celune.dev:
self.safe_log(msg, severity)
+ def _is_voice_conversion_mode(self) -> bool:
+ """Return whether the attached Celune instance is running in VC mode."""
+ return bool(
+ self.celune is not None
+ and getattr(self.celune, "vc_backend", None) is not None
+ )
+
+ @staticmethod
+ def _format_vc_pitch_shift(value: int) -> str:
+ """Return one signed semitone label for the VC pitch control."""
+ return f"{value:+d}"
+
+ def _set_vc_controls_visibility(self, visible: bool) -> None:
+ """Show or hide the VC-only controls in the bottom input row."""
+ if self.vc_mode_button is not None:
+ self.vc_mode_button.display = visible
+ if self.vc_pitch_button is not None:
+ self.vc_pitch_button.display = visible
+
+ def refresh_vc_controls(self) -> None:
+ """Refresh VC control labels and enabled state from the current engine state."""
+ if (
+ self.vc_mode_button is None
+ or self.vc_pitch_button is None
+ or self.celune is None
+ ):
+ return
+
+ is_vc_mode = self._is_voice_conversion_mode()
+ self._set_vc_controls_visibility(is_vc_mode)
+ if not is_vc_mode:
+ self._cancel_vc_recording(announce=False)
+ f0_condition = bool(getattr(self.celune, "vc_f0_condition", False))
+ pitch_shift = int(getattr(self.celune, "vc_pitch_shift", 0))
+ self.vc_mode_button.label = string(
+ "ui.vc_mode_sing" if f0_condition else "ui.vc_mode_talk"
+ )
+ self.vc_pitch_button.label = string(
+ "ui.vc_pitch_button",
+ value=self._format_vc_pitch_shift(pitch_shift),
+ )
+ self.vc_mode_button.disabled = (not is_vc_mode) or self._input_locked
+ self.vc_pitch_button.disabled = (not is_vc_mode) or self._input_locked
+
+ def set_vc_f0_condition(self, enabled: bool, announce: bool = True) -> None:
+ """Update the active VC talk-vs-sing mode in the UI and backend state.
+
+ Args:
+ enabled: Whether to enable sing-mode F0 conditioning.
+ announce: Whether to log the new mode to the user.
+ """
+ if self.celune is None:
+ return
+
+ self.celune.vc_f0_condition = enabled
+ backend = getattr(self.celune, "vc_backend", None)
+ if backend is not None and hasattr(backend, "f0_condition"):
+ setattr(backend, "f0_condition", enabled)
+ self.refresh_vc_controls()
+
+ if announce:
+ self.safe_log(
+ string(
+ "ui.vc_mode_changed",
+ mode=string("ui.vc_mode_sing" if enabled else "ui.vc_mode_talk"),
+ )
+ )
+
+ def set_vc_pitch_shift(self, value: int, announce: bool = True) -> None:
+ """Update the active VC pitch-shift value in the UI and backend state.
+
+ Args:
+ value: The requested pitch shift in semitones before clamping.
+ announce: Whether to log the new pitch shift to the user.
+ """
+ if self.celune is None:
+ return
+
+ clamped = max(_VC_PITCH_SHIFT_MIN, min(_VC_PITCH_SHIFT_MAX, value))
+ self.celune.vc_pitch_shift = clamped
+ backend = getattr(self.celune, "vc_backend", None)
+ if backend is not None and hasattr(backend, "pitch_shift"):
+ setattr(backend, "pitch_shift", clamped)
+ self.refresh_vc_controls()
+
+ if announce:
+ self.safe_log(
+ string(
+ "ui.vc_pitch_changed",
+ value=self._format_vc_pitch_shift(clamped),
+ )
+ )
+
+ def _vc_recording_active(self) -> bool:
+ """Return whether live VC recording is active in the TUI."""
+ return self._vc_recording_stream is not None
+
+ @staticmethod
+ def _vc_input_rms(audio: npt.NDArray[np.float32]) -> float:
+ """Return RMS energy for one microphone callback buffer."""
+ if audio.size == 0:
+ return 0.0
+ return float(np.sqrt(np.mean(np.square(audio), dtype=np.float64)))
+
+ @staticmethod
+ def _vc_feedback_rise_detected(previous_rms: float, current_rms: float) -> bool:
+ """Return whether the latest RMS jump looks like runaway feedback."""
+ if previous_rms < _VC_FEEDBACK_RMS_MIN_PREVIOUS:
+ return False
+ if current_rms < _VC_FEEDBACK_RMS_MIN_CURRENT:
+ return False
+ if current_rms < previous_rms * _VC_FEEDBACK_RMS_RISE_RATIO:
+ return False
+ return (current_rms - previous_rms) >= _VC_FEEDBACK_RMS_RISE_DELTA
+
+ def _request_vc_recording_feedback_stop(self) -> None:
+ """Request a feedback-triggered recording stop on a dedicated thread."""
+ stop_thread = threading.Thread(
+ target=self._stop_vc_recording_for_feedback,
+ daemon=True,
+ )
+ self._vc_recording_stop_thread = stop_thread
+ stop_thread.start()
+
+ def _flush_vc_recording_buffer_locked(self) -> Optional[npt.NDArray[np.float32]]:
+ """Return and clear the buffered microphone chunk accumulator."""
+ if not self._vc_recording_chunks:
+ return None
+ audio = np.concatenate(self._vc_recording_chunks, axis=0)
+ self._vc_recording_chunks = []
+ self._vc_recording_buffered_frames = 0
+ return audio
+
+ @staticmethod
+ def _vc_live_overlap_frames(sample_rate: int) -> int:
+ """Return the live VC overlap size for one input sample rate."""
+ return max(1, int(sample_rate * _VC_LIVE_STREAM_OVERLAP_SECONDS))
+
+ def _flush_vc_recording_chunk_locked(
+ self,
+ keep_tail_frames: int = 0,
+ ) -> Optional[npt.NDArray[np.float32]]:
+ """Return one buffered VC chunk while optionally retaining a tail overlap."""
+ audio = self._flush_vc_recording_buffer_locked()
+ if audio is None or keep_tail_frames <= 0:
+ return audio
+
+ if len(audio) <= keep_tail_frames:
+ self._vc_recording_chunks = [audio]
+ self._vc_recording_buffered_frames = len(audio)
+ return None
+
+ retained = np.asarray(audio[-keep_tail_frames:], dtype=np.float32).copy()
+ flushed = np.asarray(audio[:-keep_tail_frames], dtype=np.float32).copy()
+ self._vc_recording_chunks = [retained]
+ self._vc_recording_buffered_frames = len(retained)
+ return flushed
+
+ @staticmethod
+ def _normalize_vc_overlap_audio(
+ audio: npt.NDArray[np.float32],
+ ) -> npt.NDArray[np.float32]:
+ """Normalize one VC overlap chunk into valid mono or stereo time-first audio."""
+ normalized = np.asarray(audio, dtype=np.float32)
+ if normalized.ndim == 1:
+ return normalized
+ if normalized.ndim != 2:
+ raise ValueError(
+ f"expected 1D or 2D VC overlap audio, got {normalized.shape}"
+ )
+ if normalized.shape[1] == 1:
+ return normalized[:, 0]
+ if normalized.shape[1] == 2:
+ return normalized
+ raise ValueError(
+ f"expected mono or stereo VC overlap audio, got {normalized.shape}"
+ )
+
+ @staticmethod
+ def _crossfade_vc_overlap(
+ previous_tail: npt.NDArray[np.float32],
+ current_head: npt.NDArray[np.float32],
+ ) -> npt.NDArray[np.float32]:
+ """Crossfade two same-rate VC overlap regions into one seamless bridge."""
+ overlap_frames = min(len(previous_tail), len(current_head))
+ if overlap_frames <= 0:
+ return np.zeros((0, 2), dtype=np.float32)
+
+ previous = CeluneUI._normalize_vc_overlap_audio(previous_tail[-overlap_frames:])
+ current = CeluneUI._normalize_vc_overlap_audio(current_head[:overlap_frames])
+
+ if previous.ndim != current.ndim:
+ if previous.ndim == 1:
+ previous = np.column_stack((previous, previous))
+ if current.ndim == 1:
+ current = np.column_stack((current, current))
+
+ fade = np.linspace(0.0, 1.0, overlap_frames, dtype=np.float32)
+ if previous.ndim == 2:
+ fade = fade[:, None]
+
+ return np.asarray(
+ (previous * (1.0 - fade)) + (current * fade),
+ dtype=np.float32,
+ )
+
+ def _clear_vc_recording_state(self) -> None:
+ """Clear transient VC recording buffers after stop or cancel."""
+ self._vc_recording_stream = None
+ self._vc_recording_chunks = []
+ self._vc_recording_buffered_frames = 0
+ self._vc_recording_captured_frames = 0
+ self._vc_recording_feedback_detected = False
+ self._vc_recording_sample_rate = 0
+ self._vc_recording_label = string("ui.audio_input_label")
+ self._vc_recording_previous_rms = 0.0
+ self._vc_recording_submission_queue = None
+ self._vc_recording_stop_thread = None
+ self._vc_recording_worker = None
+
+ def _stop_vc_recording_stream(
+ self,
+ ) -> tuple[
+ Optional[sd.InputStream],
+ Optional[npt.NDArray[np.float32]],
+ int,
+ str,
+ Optional[
+ queue_module.Queue[Optional[tuple[npt.NDArray[np.float32], int, str, bool]]]
+ ],
+ int,
+ ]:
+ """Stop the active VC recording stream and return any pending live-state data."""
+ stream = self._vc_recording_stream
+ buffered_audio = self._flush_vc_recording_buffer_locked()
+ sample_rate = self._vc_recording_sample_rate
+ label = self._vc_recording_label
+ submission_queue = self._vc_recording_submission_queue
+ captured_frames = self._vc_recording_captured_frames
+ self._clear_vc_recording_state()
+ return (
+ stream,
+ buffered_audio,
+ sample_rate,
+ label,
+ submission_queue,
+ captured_frames,
+ )
+
+ @staticmethod
+ def _shutdown_vc_stream(stream: Optional[sd.InputStream]) -> None:
+ """Stop and close one VC input stream outside the recording lock."""
+ if stream is None:
+ return
+
+ with contextlib.suppress(Exception):
+ stream.stop()
+ with contextlib.suppress(Exception):
+ stream.close()
+
+ def _cancel_vc_recording(self, announce: bool = True) -> bool:
+ """Stop VC recording without submitting audio for conversion."""
+ if not self._vc_recording_active():
+ return False
+
+ with self._vc_recording_lock:
+ stream, _audio, _sample_rate, label, submission_queue, _captured_frames = (
+ self._stop_vc_recording_stream()
+ )
+ if submission_queue is not None:
+ submission_queue.put(None)
+ self._shutdown_vc_stream(stream)
+
+ if announce:
+ self.safe_log(string("ui.recording_stopped", label=label), "info")
+ return True
+
+ def _stop_vc_recording_for_feedback(self) -> None:
+ """Stop live VC recording after detecting a sudden feedback-like RMS spike."""
+ if not self._vc_recording_active():
+ return
+
+ with self._vc_recording_lock:
+ (
+ stream,
+ buffered_audio,
+ sample_rate,
+ label,
+ submission_queue,
+ _captured_frames,
+ ) = self._stop_vc_recording_stream()
+ if submission_queue is not None and buffered_audio is not None:
+ submission_queue.put((buffered_audio, sample_rate, label, True))
+ if submission_queue is not None:
+ submission_queue.put(None)
+ self._shutdown_vc_stream(stream)
+
+ self.safe_log(string("ui.recording_stopped_feedback", label=label), "warning")
+ self.update_resources()
+
+ def _start_vc_recording(self) -> bool:
+ """Start recording from the active system input device for VC."""
+ if self.celune is None or not self._is_voice_conversion_mode():
+ return False
+ if (
+ getattr(self.celune, "sleeping", False)
+ or getattr(
+ self.celune,
+ "cur_state",
+ "",
+ )
+ == "waking"
+ ):
+ return False
+
+ if self._vc_recording_active():
+ return True
+
+ input_config = getattr(self.celune, "config", None)
+ input_device_key = (
+ "input_recording_device"
+ if isinstance(input_config, dict)
+ and "input_recording_device" in input_config
+ else "input_device"
+ )
+ input_device, direct_device_info = resolve_audio_device_with_info(
+ input_config,
+ input_device_key,
+ "input",
+ )
+
+ try:
+ device_info = (
+ cast(dict[str, _AudioDeviceScalar], dict(direct_device_info))
+ if direct_device_info is not None
+ else cast(
+ dict[str, _AudioDeviceScalar],
+ sd.query_devices(device=input_device, kind="input"),
+ )
+ )
+ except Exception as e:
+ self.safe_log(
+ string(
+ "ui.recording_open_input_failed",
+ error=format_error(e, self.celune.dev),
+ ),
+ "error",
+ )
+ return False
+
+ channels = _device_scalar_int(device_info.get("max_input_channels"), 0)
+ if channels <= 0:
+ self.safe_log(string("pipeline.no_audio_device"), "warning")
+ return False
+
+ sample_rate = _device_scalar_int(
+ device_info.get("default_samplerate"),
+ 48000,
+ )
+ label = str(device_info.get("name", string("ui.audio_input_label")))
+ channel_count = 2 if channels >= 2 else 1
+ stream_chunk_frames = max(
+ int(sample_rate * _VC_LIVE_STREAM_CHUNK_SECONDS),
+ 1,
+ )
+ overlap_frames = min(
+ self._vc_live_overlap_frames(sample_rate),
+ max(stream_chunk_frames - 1, 1),
+ )
+ submission_queue: queue_module.Queue[
+ Optional[tuple[npt.NDArray[np.float32], int, str, bool]]
+ ] = queue_module.Queue()
+
+ def submit_live_audio() -> None:
+ pending_tail: Optional[npt.NDArray[np.float32]] = None
+ pending_sample_rate = 0
+ pending_label = string("ui.audio_input_label")
+
+ def queue_playback_segment(
+ audio: npt.NDArray[np.float32],
+ playback_sample_rate: int,
+ playback_label: str,
+ ) -> None:
+ if len(audio) <= 0 or self.celune is None:
+ return
+ if not self.celune.play_audio(
+ np.asarray(audio, dtype=np.float32),
+ playback_sample_rate,
+ label=playback_label,
+ ):
+ self.safe_log(
+ string("ui.recording_stream_submit_failed"),
+ "warning",
+ )
+
+ while True:
+ item = submission_queue.get()
+ if item is None:
+ if pending_tail is not None and len(pending_tail) > 0:
+ queue_playback_segment(
+ pending_tail,
+ pending_sample_rate,
+ pending_label,
+ )
+ return
+
+ audio, queued_sample_rate, queued_label, is_final_chunk = item
+ try:
+ if self.celune is None:
+ continue
+ converted = self.celune.convert_audio(
+ audio,
+ queued_sample_rate,
+ label=queued_label,
+ )
+ if converted is None:
+ self.safe_log(
+ string("ui.recording_stream_submit_failed"),
+ "warning",
+ )
+ continue
+
+ converted_audio = np.asarray(converted.audio, dtype=np.float32)
+ playback_sample_rate = converted.sample_rate
+ playback_label = converted.label
+ playback_overlap_frames = min(
+ self._vc_live_overlap_frames(playback_sample_rate),
+ max(len(converted_audio) - 1, 0),
+ )
+
+ if (
+ pending_tail is not None
+ and pending_sample_rate != playback_sample_rate
+ ):
+ queue_playback_segment(
+ pending_tail,
+ pending_sample_rate,
+ pending_label,
+ )
+ pending_tail = None
+ pending_sample_rate = 0
+ pending_label = playback_label
+
+ if pending_tail is None:
+ if is_final_chunk or playback_overlap_frames <= 0:
+ queue_playback_segment(
+ converted_audio,
+ playback_sample_rate,
+ playback_label,
+ )
+ continue
+
+ if len(converted_audio) <= playback_overlap_frames:
+ pending_tail = converted_audio.copy()
+ pending_sample_rate = playback_sample_rate
+ pending_label = playback_label
+ continue
+
+ queue_playback_segment(
+ converted_audio[:-playback_overlap_frames],
+ playback_sample_rate,
+ playback_label,
+ )
+ pending_tail = converted_audio[-playback_overlap_frames:].copy()
+ pending_sample_rate = playback_sample_rate
+ pending_label = playback_label
+ continue
+
+ overlap_bridge = self._crossfade_vc_overlap(
+ pending_tail,
+ converted_audio[:playback_overlap_frames],
+ )
+ crossfade_frames = len(overlap_bridge)
+ converted_remainder = converted_audio[
+ min(len(converted_audio), crossfade_frames) :
+ ]
+
+ if is_final_chunk or playback_overlap_frames <= 0:
+ segment_parts = [overlap_bridge]
+ if len(converted_remainder) > 0:
+ segment_parts.append(converted_remainder)
+ if segment_parts:
+ queue_playback_segment(
+ np.concatenate(segment_parts),
+ playback_sample_rate,
+ playback_label,
+ )
+ pending_tail = None
+ pending_sample_rate = 0
+ continue
+
+ if len(converted_remainder) <= playback_overlap_frames:
+ if len(overlap_bridge) > 0:
+ queue_playback_segment(
+ overlap_bridge,
+ playback_sample_rate,
+ playback_label,
+ )
+ pending_tail = converted_remainder.copy()
+ pending_sample_rate = playback_sample_rate
+ pending_label = playback_label
+ continue
+
+ segment_parts = [overlap_bridge]
+ body = converted_remainder[:-playback_overlap_frames]
+ if len(body) > 0:
+ segment_parts.append(body)
+ queue_playback_segment(
+ np.concatenate(segment_parts),
+ playback_sample_rate,
+ playback_label,
+ )
+ pending_tail = converted_remainder[-playback_overlap_frames:].copy()
+ pending_sample_rate = playback_sample_rate
+ pending_label = playback_label
+ except Exception as e:
+ if self.celune is None:
+ continue
+ self.safe_log(
+ string(
+ "ui.recording_stream_chunk_failed",
+ label=queued_label,
+ error=format_error(e, self.celune.dev),
+ ),
+ "warning",
+ )
+
+ worker = threading.Thread(target=submit_live_audio, daemon=True)
+
+ def callback(
+ indata: npt.NDArray[np.float32],
+ frames: int,
+ time_info: Optional[tuple[float, float, float]],
+ status: Optional[sd.CallbackFlags],
+ ) -> None:
+ discard(frames)
+ discard(time_info)
+ discard(status)
+ callback_audio = np.asarray(indata, dtype=np.float32).copy()
+ current_rms = self._vc_input_rms(callback_audio)
+ should_stop_for_feedback = False
+ with self._vc_recording_lock:
+ if self._vc_recording_stream is None:
+ return
+ if self._vc_recording_feedback_detected:
+ return
+ previous_rms = self._vc_recording_previous_rms
+ self._vc_recording_previous_rms = current_rms
+ self._vc_recording_captured_frames += len(callback_audio)
+
+ if (
+ not self._vc_recording_feedback_detected
+ and self._vc_feedback_rise_detected(previous_rms, current_rms)
+ ):
+ self._vc_recording_feedback_detected = True
+ should_stop_for_feedback = True
+ else:
+ self._vc_recording_chunks.append(callback_audio)
+ self._vc_recording_buffered_frames += len(callback_audio)
+ if (
+ self._vc_recording_buffered_frames >= stream_chunk_frames
+ and self._vc_recording_submission_queue is not None
+ ):
+ buffered_audio = self._flush_vc_recording_chunk_locked(
+ keep_tail_frames=overlap_frames,
+ )
+ if buffered_audio is not None:
+ self._vc_recording_submission_queue.put(
+ (buffered_audio, sample_rate, label, False)
+ )
+
+ if should_stop_for_feedback:
+ self._request_vc_recording_feedback_stop()
+
+ try:
+ stream = sd.InputStream(
+ samplerate=sample_rate,
+ channels=channel_count,
+ dtype="float32",
+ callback=callback,
+ device=input_device,
+ )
+ stream.start()
+ except Exception as e:
+ self.safe_log(
+ string(
+ "ui.recording_start_failed",
+ label=label,
+ error=format_error(e, self.celune.dev),
+ ),
+ "error",
+ )
+ return False
+
+ with self._vc_recording_lock:
+ self._vc_recording_stream = stream
+ self._vc_recording_chunks = []
+ self._vc_recording_buffered_frames = 0
+ self._vc_recording_captured_frames = 0
+ self._vc_recording_feedback_detected = False
+ self._vc_recording_sample_rate = sample_rate
+ self._vc_recording_label = label
+ self._vc_recording_previous_rms = 0.0
+ self._vc_recording_submission_queue = submission_queue
+ self._vc_recording_stop_thread = None
+ self._vc_recording_worker = worker
+
+ worker.start()
+ self.safe_log(string("ui.recording_started", label=label), "info")
+ self.update_resources()
+ return True
+
+ def toggle_vc_recording(self) -> bool:
+ """Toggle live VC recording for the current input device.
+
+ Returns:
+ bool: ``True`` when recording started or stopped successfully.
+ """
+ if self._vc_recording_active():
+ with self._vc_recording_lock:
+ (
+ stream,
+ buffered_audio,
+ sample_rate,
+ label,
+ submission_queue,
+ captured_frames,
+ ) = self._stop_vc_recording_stream()
+ if submission_queue is not None and buffered_audio is not None:
+ submission_queue.put((buffered_audio, sample_rate, label, True))
+ if submission_queue is not None:
+ submission_queue.put(None)
+ self._shutdown_vc_stream(stream)
+
+ self.safe_log(string("ui.recording_stopped", label=label), "info")
+ self.update_resources()
+ if captured_frames <= 0:
+ self.safe_log(string("ui.recording_empty"), "warning")
+ return False
+ return True
+
+ return self._start_vc_recording()
+
def tts_voice_changed(self, name: str) -> None:
"""Set UI state after changing Celune's voice.
@@ -1171,11 +2127,13 @@ def tts_voice_changed(self, name: str) -> None:
if threading.current_thread() is threading.main_thread():
self.style_button.label = label
+ self.refresh_vc_controls()
self.update_resources()
else:
def update() -> None:
self.style_button.label = label
+ self.refresh_vc_controls()
self.update_resources()
self.call_from_thread(update)
@@ -1242,15 +2200,24 @@ def _submit_text(self, text: str, process_commands: bool = True) -> bool:
if not text:
return False
+ if self._is_ui_test_mode():
+ self._suppress_input_change = True
+ try:
+ self.input_box.load_text("")
+ finally:
+ self._suppress_input_change = False
+ self.safe_status(string("ui.test_mode_active"))
+ return True
+
if self.celune.cur_state == "waking":
self._cancel_sleep_timer()
- self.safe_status("Waking up")
+ self.safe_status(string("status.waking_up"))
self.change_input_state(locked=True)
return True
if self.celune.sleeping:
self._cancel_sleep_timer()
- self.safe_status("Waking up")
+ self.safe_status(string("status.waking_up"))
self._suppress_input_change = True
try:
self.input_box.load_text("")
@@ -1264,7 +2231,10 @@ def _submit_text(self, text: str, process_commands: bool = True) -> bool:
try:
parts = self.split_command_input(text[1:])
except ValueError as e:
- self.safe_log(f"Command parsing error: {e}", "error")
+ self.safe_log(
+ string("ui.command_parsing_error", error=e),
+ "error",
+ )
return False
if not parts:
@@ -1294,7 +2264,7 @@ def _submit_text(self, text: str, process_commands: bool = True) -> bool:
self._cancel_sleep_timer()
self.style_button.disabled = True
- self.input_box.placeholder = "Please wait"
+ self.input_box.placeholder = string("ui.wait_placeholder")
self.input_box.load_text("")
self.update_resources()
return True
@@ -1395,7 +2365,7 @@ def replace_input(value: str) -> None:
finally:
self._suppress_input_change = False
- self.call_from_thread(replace_input, "")
+ self.call_from_thread(lambda: replace_input(""))
for char in typing_animation(text):
if cancellable and token != self._tutorial_token:
@@ -1403,7 +2373,7 @@ def replace_input(value: str) -> None:
if self.cur_state == "exiting":
return
typed += char
- self.call_from_thread(replace_input, typed)
+ self.call_from_thread(lambda value=typed: replace_input(value))
final_char = text[-1] if text else " "
time.sleep(typing_delay(final_char))
@@ -1411,7 +2381,9 @@ def replace_input(value: str) -> None:
if self.cur_state != "exiting" and (
not cancellable or token == self._tutorial_token
):
- self.call_from_thread(self._submit_text, typed, process_commands)
+ self.call_from_thread(
+ lambda value=typed: self._submit_text(value, process_commands)
+ )
threading.Thread(target=worker, daemon=True).start()
@@ -1458,6 +2430,24 @@ def on_key(self, event: events.Key) -> None:
event.prevent_default()
return
+ if event.key == "ctrl+r":
+ if getattr(self.celune, "sleeping", False):
+ self._cancel_sleep_timer()
+ self.safe_status(string("status.waking_up"))
+ self.change_input_state(locked=True)
+ self.wake_from_sleep()
+ event.prevent_default()
+ event.stop()
+ return
+ if getattr(self.celune, "cur_state", "") == "waking":
+ event.prevent_default()
+ event.stop()
+ return
+ if self.toggle_vc_recording():
+ event.prevent_default()
+ event.stop()
+ return
+
if event.key == "ctrl+j":
if self._submit_text(self.input_box.text):
event.prevent_default()
@@ -1474,16 +2464,32 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
if self.celune.is_in_tutorial:
return
+ if event.button == self.vc_mode_button:
+ if self._is_voice_conversion_mode():
+ self.set_vc_f0_condition(
+ not bool(getattr(self.celune, "vc_f0_condition", False))
+ )
+ return
+
+ if event.button == self.vc_pitch_button:
+ if self._is_voice_conversion_mode():
+ current_value = int(getattr(self.celune, "vc_pitch_shift", 0))
+ next_value = current_value + 1
+ if next_value > _VC_PITCH_SHIFT_MAX:
+ next_value = _VC_PITCH_SHIFT_MIN
+ self.set_vc_pitch_shift(next_value)
+ return
+
if event.button != self.style_button:
return
if len(self.celune.voices) == 0 or not self.celune_styles:
- self.safe_log("No voices are loaded.", "warning")
+ self.safe_log(string("ui.no_voices_loaded"), "warning")
self.change_voice_lock_state(locked=True)
return
- if not self.celune_ready:
- self.safe_log("Core engine is not loaded.", "warning")
+ if not self.celune_ready and not self.celune.backend.is_fake:
+ self.safe_log(string("ui.core_engine_not_loaded"), "warning")
self.change_voice_lock_state(locked=True)
return
@@ -1497,6 +2503,10 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
def on_unmount(self) -> None:
"""Unload Celune."""
+ self._write_terminal_escape(
+ f"\x1b]2;{string('osc.exiting', app_name=APP_NAME)}\x07"
+ )
+ self._cancel_vc_recording(announce=False)
if self.celune is not None:
self.celune.close()
@@ -1510,22 +2520,28 @@ def tts_idle(self) -> None:
"""Reset UI state after Celune stops talking."""
if self.cur_state in {"exiting", "error"} or not self.celune_ready:
if self.input_box is not None:
- self.input_box.placeholder = "Please wait"
+ self.input_box.placeholder = string("ui.wait_placeholder")
if self.style_button is not None:
self.style_button.disabled = True
return
+ if self.celune.cur_state in {"reloading", "waking"}:
+ self.change_input_state(locked=True)
+ self.change_voice_lock_state(locked=True)
+ if self.celune.cur_state == "waking":
+ self.safe_status(string("status.waking_up"))
+ return
self.celune.locked = False
if self.celune.sleeping:
- self.safe_status("Sleeping", "sleeping")
+ self.safe_status(string("status.sleeping"), "sleeping")
return
self.celune.cur_state = "idle"
if self.celune.is_in_tutorial:
- self.input_box.placeholder = "Currently in tutorial mode"
+ self.input_box.placeholder = string("ui.tutorial_placeholder")
self.style_button.disabled = True
else:
self.change_input_state(locked=False)
self.change_voice_lock_state(locked=len(self.celune.voices) < 2)
- self.safe_status("Idle")
+ self.safe_status(string("status.idle"))
self._schedule_sleep_timer()
def tts_queue_avail(
@@ -1536,9 +2552,9 @@ def tts_queue_avail(
return
self.celune.locked = False
self._cancel_sleep_timer()
- self.safe_status("Speaking")
+ self.safe_status(string("status.speaking"))
if self.celune.is_in_tutorial:
- self.input_box.placeholder = "Currently in tutorial mode"
+ self.input_box.placeholder = string("ui.tutorial_placeholder")
self.style_button.disabled = True
else:
self.change_input_state(locked=False)
@@ -1623,6 +2639,7 @@ def _graceful_exit(self) -> None:
"""Exit from Celune gracefully."""
# while Python cleanup would tear down the core, we'd rather explicitly tell Celune to shut down
# before we tell Textual to exit its main loop
+ self._cancel_vc_recording(announce=False)
self.celune.close()
self.exit()
diff --git a/celune/ui/commands.py b/celune/ui/commands.py
index 3df719d..602a013 100644
--- a/celune/ui/commands.py
+++ b/celune/ui/commands.py
@@ -13,9 +13,11 @@
from ..paths import project_root
from ..constants import APP_NAME
-from ..backends.qwen3 import Qwen3
+from ..backends.tts.qwen3 import Qwen3
from ..exceptions import InvalidExtensionError
from ..utils import format_error, replace_ipa, format_number
+from ..cevoice import active_bundle_path, resolve_bundle_path
+from ..i18n import string
if TYPE_CHECKING:
from .app import CeluneUI
@@ -69,7 +71,7 @@ def tutorial(ui: CeluneUI) -> None:
if not assets.exists():
assets = project_root() / "assets"
if not assets.exists():
- ui.safe_log("No tutorial assets found.", "warning")
+ ui.safe_log(string("commands.no_tutorial_assets"), "warning")
return
clips = (
@@ -99,7 +101,10 @@ def worker() -> None:
ui.celune.play(str(pth))
except Exception as exc:
ui.safe_log(
- f"Tutorial playback failed: {format_error(exc, ui.celune.dev)}",
+ string(
+ "commands.tutorial_playback_failed",
+ error=format_error(exc, ui.celune.dev),
+ ),
"warning",
)
ui.call_from_thread(ui.cancel_tutorial, True)
@@ -112,7 +117,10 @@ def worker() -> None:
)
except Exception as e:
ui.safe_log(
- f"Tutorial failed: {format_error(e, ui.celune.dev)}",
+ string(
+ "commands.tutorial_failed",
+ error=format_error(e, ui.celune.dev),
+ ),
"warning",
)
ui.call_from_thread(ui.cancel_tutorial, True)
@@ -150,61 +158,83 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None:
"""
ui.input_box.load_text("")
- if command == "help":
- ui.safe_log(f"--- {APP_NAME} help topics ---")
- ui.safe_log("Available commands:")
- ui.safe_log(
- "Arguments marked in <> are required, those marked in [] are optional."
- )
- ui.safe_log(
- f"/consumebuffer - Make {APP_NAME} consume text from the live buffer without "
- "pressing CTRL+ENTER."
- )
- ui.safe_log("Caution: This feature may interfere with typing '...'.", "warning")
+
+ def refresh_vc_controls() -> None:
+ refresh = getattr(ui, "refresh_vc_controls", None)
+ if callable(refresh):
+ refresh()
+
+ def set_vc_f0_condition(enabled: bool) -> None:
+ setter = getattr(ui, "set_vc_f0_condition", None)
+ if callable(setter):
+ setter(enabled)
+ return
+
+ ui.celune.vc_f0_condition = enabled
+ backend = getattr(ui.celune, "vc_backend", None)
+ if backend is not None and hasattr(backend, "f0_condition"):
+ setattr(backend, "f0_condition", enabled)
+ refresh_vc_controls()
ui.safe_log(
- f"/invoke [args] - Invoke a {APP_NAME} extension by its name."
+ string(
+ "commands.vcmode_set",
+ mode=string("ui.vc_mode_sing" if enabled else "ui.vc_mode_talk"),
+ )
)
- ui.safe_log(f"/extensions - List currently available {APP_NAME} extensions.")
- if ui.celune.backend.name != "mini":
- ui.safe_log(
- f"/voiceprompt - Change {APP_NAME}'s voice prompt. This will allow you to steer her voice."
- )
- ui.safe_log(
- "Caution: Some prompts may cause adverse effects. Choose prompts that enhance personality, "
- "rather than replace it.",
- "warning",
- )
+ def set_vc_pitch_shift(value: int) -> None:
+ setter = getattr(ui, "set_vc_pitch_shift", None)
+ if callable(setter):
+ setter(value)
+ return
- ui.safe_log("/speed - Change speaking speed.")
- ui.safe_log("/reverb - Change reverb strength.")
+ ui.celune.vc_pitch_shift = value
+ backend = getattr(ui.celune, "vc_backend", None)
+ if backend is not None and hasattr(backend, "pitch_shift"):
+ setattr(backend, "pitch_shift", value)
+ refresh_vc_controls()
+ ui.safe_log(string("commands.vcpitch_set", value=value))
+
+ if command == "help":
+ ui.safe_log(string("commands.help_header", app_name=APP_NAME))
+ ui.safe_log(string("commands.help_available"))
+ ui.safe_log(string("commands.help_arguments"))
+ ui.safe_log(string("commands.help_consumebuffer", app_name=APP_NAME))
+ ui.safe_log(string("commands.help_consumebuffer_caution"), "warning")
+ ui.safe_log(string("commands.help_invoke", app_name=APP_NAME))
+ ui.safe_log(string("commands.help_extensions", app_name=APP_NAME))
+
+ if ui.celune.backend.name != "mini":
+ ui.safe_log(string("commands.help_voiceprompt", app_name=APP_NAME))
+ ui.safe_log(string("commands.help_voiceprompt_caution"), "warning")
+
+ ui.safe_log(string("commands.help_speed"))
+ ui.safe_log(string("commands.help_reverb"))
+ ui.safe_log(string("commands.help_backend"))
+ ui.safe_log(string("commands.help_cevoice"))
+ if getattr(ui.celune, "input_mode", "text_to_speech") == "voice_conversion":
+ ui.safe_log(string("commands.help_vc"))
+ ui.safe_log(string("commands.help_vcmode"))
+ ui.safe_log(string("commands.help_vcpitch"))
if ui.celune.backend.name == "qwen3":
- ui.safe_log(
- "/xvectoronly - Toggle Qwen3 identity-only cloning."
- )
+ ui.safe_log(string("commands.help_xvectoronly"))
- ui.safe_log("/play - Play a sound effect by path.")
+ ui.safe_log(string("commands.help_play"))
if ui.celune.vision is not None:
- ui.safe_log(
- "/attach [file...] - Attach one or more images or videos to the next persona reply."
- )
- ui.safe_log(
- "/say - Speak text directly and bypass Persona for this one message."
- )
-
- ui.safe_log(
- "/seed [seed|random] - Set or clear the seed for speech outputs, affecting pronunciation and/or prosody."
- )
- ui.safe_log(f"/tutorial - Run {APP_NAME}'s tutorial.")
- ui.safe_log("/stop - Terminate ongoing speech.")
- ui.safe_log(f"/exit - Exit {APP_NAME}.")
- ui.safe_log("/help - Display this help message.")
+ ui.safe_log(string("commands.help_attach"))
+ ui.safe_log(string("commands.help_say"))
+
+ ui.safe_log(string("commands.help_seed"))
+ ui.safe_log(string("commands.help_tutorial", app_name=APP_NAME))
+ ui.safe_log(string("commands.help_stop"))
+ ui.safe_log(string("commands.help_exit", app_name=APP_NAME))
+ ui.safe_log(string("commands.help_help"))
return
if command == "consumebuffer":
if not args:
- ui.safe_log("Usage: /consumebuffer ", "warning")
+ ui.safe_log(string("commands.usage_consumebuffer"), "warning")
return
if args[0].lower() in ["true", "false"]:
@@ -212,19 +242,21 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None:
ui.consume_on_boundary = boolean
if boolean:
- ui.safe_log("Now consuming from live input")
+ ui.safe_log(string("commands.consuming_live_input"))
else:
- ui.safe_log("No longer consuming from live input")
+ ui.safe_log(string("commands.not_consuming_live_input"))
return
- ui.safe_log(f"Invalid argument for '{command}', must be true/false.", "warning")
+ ui.safe_log(
+ string("commands.invalid_true_false_argument", command=command), "warning"
+ )
return
if command == "invoke":
if not args:
- ui.safe_log("Usage: /invoke [args]")
+ ui.safe_log(string("commands.usage_invoke"))
return
if not ui.celune.extension_manager:
- ui.safe_log("Extension system not initialized.", "warning")
+ ui.safe_log(string("commands.extension_system_not_initialized"), "warning")
return
name = args[0]
@@ -233,34 +265,34 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None:
try:
ui.celune.extension_manager.invoke(name, *invoke_args)
except InvalidExtensionError:
- ui.safe_log(f"Extension not found: {name}", "warning")
+ ui.safe_log(string("commands.extension_not_found", name=name), "warning")
except Exception as e:
- ui.safe_log(f"[EXT ERROR] {e}", "error")
+ ui.safe_log(string("commands.extension_error", error=e), "error")
return
if command == "extensions":
if not ui.celune.extension_manager:
- ui.safe_log("Extension system not initialized.", "warning")
+ ui.safe_log(string("commands.extension_system_not_initialized"), "warning")
return
names = ui.celune.extension_manager.list_extensions()
if not names:
- ui.safe_log("No extensions loaded.", "warning")
+ ui.safe_log(string("commands.no_extensions_loaded"), "warning")
else:
- ui.safe_log("Extensions: " + ", ".join(names))
+ ui.safe_log(string("commands.extensions_list", names=", ".join(names)))
return
if command == "voiceprompt":
voice_prompt_supported = getattr(ui.celune, "voice_prompt_supported", None)
if callable(voice_prompt_supported) and not voice_prompt_supported():
ui.celune.voice_prompt = None
ui.safe_log(
- "Voice prompts are unavailable with the currently loaded model.",
+ string("commands.voice_prompts_unavailable"),
"warning",
)
return
if not args:
- ui.safe_log("Usage: /voiceprompt ", "warning")
+ ui.safe_log(string("commands.usage_voiceprompt"), "warning")
return
new_prompt = " ".join(args).strip()
@@ -268,18 +300,21 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None:
if not new_prompt or new_prompt.lower() == "clear":
ui.celune.voice_prompt = None
- ui.safe_log("Voice prompt cleared.")
+ ui.safe_log(string("commands.voice_prompt_cleared"))
return
- ui.safe_log(f"Voice prompt set to '{new_prompt}'.")
+ ui.safe_log(string("commands.voice_prompt_set", prompt=new_prompt))
return
if command == "speed":
if not ui.celune.can_use_rubberband:
- ui.safe_log(f"{APP_NAME} cannot currently use Rubber Band.", "warning")
+ ui.safe_log(
+ string("commands.rubber_band_unavailable", app_name=APP_NAME),
+ "warning",
+ )
return
if not args:
- ui.safe_log("Usage: /speed ", "warning")
+ ui.safe_log(string("commands.usage_speed"), "warning")
return
try:
@@ -289,17 +324,17 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None:
speed = float(args[0])
float_speed = speed / 100.0
if not 0.8 <= float_speed <= 1.2:
- ui.safe_log("Value out of range. Expected 80-120%.", "warning")
+ ui.safe_log(string("commands.value_out_of_range_speed"), "warning")
return
ui.celune.speed = float_speed
except ValueError:
- ui.safe_log(f"Invalid argument: {args[0]}", "warning")
+ ui.safe_log(string("commands.invalid_argument", value=args[0]), "warning")
else:
- ui.safe_log(f"Speaking speed set to {args[0]}%.")
+ ui.safe_log(string("commands.speed_set", value=args[0]))
return
if command == "reverb":
if not args:
- ui.safe_log("Usage: /reverb ", "warning")
+ ui.safe_log(string("commands.usage_reverb"), "warning")
return
try:
@@ -309,41 +344,209 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None:
strength = float(args[0])
float_strength = strength / 100.0
if not 0.0 <= float_strength <= 1.0:
- ui.safe_log("Value out of range. Expected 0-100%.", "warning")
+ ui.safe_log(string("commands.value_out_of_range_reverb"), "warning")
return
ui.celune.reverb.strength = float_strength
except ValueError:
- ui.safe_log(f"Invalid argument: {args[0]}", "warning")
+ ui.safe_log(string("commands.invalid_argument", value=args[0]), "warning")
+ else:
+ ui.safe_log(string("commands.reverb_set", value=args[0]))
+ return
+ if command == "backend":
+ if not args:
+ ui.safe_log(string("commands.usage_backend"), "warning")
+ return
+
+ backend_name = args[0]
+
+ active_backend = getattr(ui.celune, "_active_runtime_backend_name", None)
+ if callable(active_backend):
+ active_backend_name = active_backend()
+ elif getattr(ui.celune, "input_mode", "text_to_speech") == "voice_conversion":
+ backend = getattr(ui.celune, "vc_backend", None)
+ active_backend_name = getattr(backend, "name", "")
else:
- ui.safe_log(f"Reverb strength set to {args[0]}%.")
+ backend = getattr(ui.celune, "backend", None)
+ active_backend_name = getattr(backend, "name", "")
+ if backend_name == active_backend_name:
+ ui.safe_log(string("commands.backend_already_loaded"), "warning")
+ return
+
+ def backend_worker() -> None:
+ try:
+ if ui.celune.set_backend_and_wait(backend_name):
+ ui.celune.try_play_signal("readiness")
+ ui.safe_log(
+ string("commands.backend_switched", backend_name=backend_name)
+ )
+ else:
+ ui.safe_log(string("commands.backend_not_switched"), "warning")
+ except Exception as exc:
+ ui.safe_log(
+ string(
+ "commands.backend_switch_failed",
+ error=format_error(exc, ui.celune.dev),
+ ),
+ "error",
+ )
+
+ threading.Thread(target=backend_worker, daemon=True).start()
+ return
+ if command == "cevoice":
+ if not args:
+ ui.safe_log(string("commands.usage_cevoice"), "warning")
+ return
+
+ bundle = args[0]
+ if resolve_bundle_path(bundle) == active_bundle_path():
+ ui.safe_log(string("commands.character_already_loaded"), "warning")
+ return
+
+ def cevoice_worker() -> None:
+ try:
+ if ui.celune.set_cevoice_and_wait(bundle):
+ ui.safe_log(string("commands.character_changed", bundle=bundle))
+ else:
+ ui.safe_log(
+ string("commands.character_not_switched", bundle=bundle),
+ "warning",
+ )
+ except Exception as exc:
+ ui.safe_log(
+ string(
+ "commands.character_switch_failed",
+ error=format_error(exc, ui.celune.dev),
+ ),
+ "error",
+ )
+
+ threading.Thread(target=cevoice_worker, daemon=True).start()
+ return
+ if command == "vc":
+ if getattr(ui.celune, "input_mode", "text_to_speech") != "voice_conversion":
+ ui.safe_log(string("commands.voice_conversion_only"), "warning")
+ return
+
+ if not args:
+ ui.safe_log(string("commands.usage_vc"), "warning")
+ return
+
+ source_path = Path(args[0]).expanduser()
+ if not source_path.exists() or not source_path.is_file():
+ ui.safe_log(
+ string("commands.vc_file_not_found", path=args[0]),
+ "warning",
+ )
+ return
+
+ def vc_worker() -> None:
+ try:
+ audio, sample_rate = sf.read(
+ str(source_path),
+ dtype="float32",
+ always_2d=False,
+ )
+ except Exception as exc:
+ ui.safe_log(
+ string(
+ "commands.vc_decode_failed",
+ error=format_error(exc, ui.celune.dev),
+ ),
+ "error",
+ )
+ return
+
+ if ui.celune.submit_audio(
+ audio,
+ sample_rate,
+ label=source_path.name,
+ ):
+ ui.safe_log(
+ string("commands.vc_submitted", path=str(source_path)),
+ )
+ return
+
+ ui.safe_log(
+ string("commands.vc_submission_failed", path=str(source_path)),
+ "warning",
+ )
+
+ threading.Thread(target=vc_worker, daemon=True).start()
+ return
+ if command == "vcmode":
+ if getattr(ui.celune, "input_mode", "text_to_speech") != "voice_conversion":
+ ui.safe_log(string("commands.voice_conversion_only"), "warning")
+ return
+
+ if not args:
+ ui.safe_log(string("commands.usage_vcmode"), "warning")
+ return
+
+ mode = args[0].lower()
+ if mode == "talk":
+ set_vc_f0_condition(False)
+ return
+ if mode == "sing":
+ set_vc_f0_condition(True)
+ return
+
+ ui.safe_log(string("commands.usage_vcmode"), "warning")
+ return
+ if command == "vcpitch":
+ if getattr(ui.celune, "input_mode", "text_to_speech") != "voice_conversion":
+ ui.safe_log(string("commands.voice_conversion_only"), "warning")
+ return
+
+ if not args:
+ ui.safe_log(string("commands.usage_vcpitch"), "warning")
+ return
+
+ raw_value = args[0].lower()
+ if raw_value == "clear":
+ set_vc_pitch_shift(0)
+ return
+
+ try:
+ semitones = int(raw_value)
+ except ValueError:
+ ui.safe_log(string("commands.usage_vcpitch"), "warning")
+ return
+
+ if not -12 <= semitones <= 12:
+ ui.safe_log(string("commands.vcpitch_range"), "warning")
+ return
+
+ set_vc_pitch_shift(semitones)
return
if command == "xvectoronly":
backend = ui.celune.backend
if not isinstance(backend, Qwen3):
- ui.safe_log(
- "This setting is only available on the Qwen3 backend.", "warning"
- )
+ ui.safe_log(string("commands.xvectoronly_qwen3_only"), "warning")
return
if not args:
- ui.safe_log("Usage: /xvectoronly ", "warning")
+ ui.safe_log(string("commands.usage_xvectoronly"), "warning")
return
value = args[0].lower()
if value not in {"true", "false"}:
ui.safe_log(
- f"Invalid argument for '{command}', must be true/false.",
+ string("commands.invalid_true_false_argument", command=command),
"warning",
)
return
backend.x_vector_only = value == "true"
- state = "enabled" if backend.x_vector_only else "disabled"
- ui.safe_log(f"Qwen3 identity-only cloning {state}.")
+ state = string(
+ "commands.state_enabled"
+ if backend.x_vector_only
+ else "commands.state_disabled"
+ )
+ ui.safe_log(string("commands.qwen3_identity_only_cloning", state=state))
return
if command == "play":
if not args:
- ui.safe_log("Usage: /play [volume]", "warning")
+ ui.safe_log(string("commands.usage_play"), "warning")
return
try:
@@ -353,7 +556,7 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None:
volume = float(args[1])
except ValueError:
ui.safe_log(
- f"Invalid volume for '{command}', must be numeric.",
+ string("commands.invalid_volume", command=command),
"warning",
)
return
@@ -364,40 +567,53 @@ def worker() -> None:
return
if args[0].startswith("https://"):
ui.safe_log(
- f"Playing YouTube audio at {format_number(volume * 100)}% volume"
+ string(
+ "commands.playing_youtube_audio",
+ volume=format_number(volume * 100),
+ )
)
else:
ui.safe_log(
- f"Playing {args[0]} at {format_number(volume * 100)}% volume"
+ string(
+ "commands.playing_audio",
+ path=args[0],
+ volume=format_number(volume * 100),
+ )
)
except Exception as exc:
ui.safe_log(
- f"Cannot play this audio: {format_error(exc, ui.celune.dev)}",
+ string(
+ "commands.cannot_play_audio",
+ error=format_error(exc, ui.celune.dev),
+ ),
"error",
)
threading.Thread(target=worker, daemon=True).start()
except Exception as e:
ui.safe_log(
- f"Cannot play this file: {format_error(e, ui.celune.dev)}",
+ string(
+ "commands.cannot_play_file",
+ error=format_error(e, ui.celune.dev),
+ ),
"error",
)
return
return
if command == "attach":
if not args:
- ui.safe_log("Usage: /attach [file...]", "warning")
+ ui.safe_log(string("commands.usage_attach"), "warning")
return
if len(args) == 1 and args[0].lower() in {"clear", "reset", "none"}:
ui.celune.persona_attachments.clear()
- ui.safe_log("Attachments cleared.")
+ ui.safe_log(string("commands.attachments_cleared"))
return
vision = getattr(ui.celune, "vision", "available")
if vision is None:
ui.safe_log(
- f"Cannot add attachments while {APP_NAME} is running in speech-only mode.",
+ string("commands.attachments_speech_only_mode", app_name=APP_NAME),
"warning",
)
return
@@ -416,7 +632,9 @@ def worker() -> None:
path = Path(raw_path).expanduser()
if not path.exists() or not path.is_file():
- ui.safe_log(f"Attachment not found: {raw_path}", "warning")
+ ui.safe_log(
+ string("commands.attachment_not_found", path=raw_path), "warning"
+ )
continue
suffix = path.suffix.lower()
@@ -425,7 +643,10 @@ def worker() -> None:
elif suffix in VIDEO_EXTENSIONS:
kind = "video"
else:
- ui.safe_log(f"Unsupported attachment type: {raw_path}", "warning")
+ ui.safe_log(
+ string("commands.unsupported_attachment_type", path=raw_path),
+ "warning",
+ )
continue
resolved = path.resolve()
@@ -443,22 +664,26 @@ def worker() -> None:
count = len(ui.celune.persona_attachments)
ui.safe_log(
- f"Attached {', '.join(added)}. {count} attachments will be sent in the next pass."
+ string(
+ "commands.attachments_added",
+ names=", ".join(added),
+ count=count,
+ )
)
return
if command == "say":
if not args:
- ui.safe_log("Usage: /say ", "warning")
+ ui.safe_log(string("commands.usage_say"), "warning")
return
raw_text = " ".join(args).strip()
if not raw_text:
- ui.safe_log("Usage: /say ", "warning")
+ ui.safe_log(string("commands.usage_say"), "warning")
return
if not ui.celune.vision:
- ui.safe_log("This command is redundant in the current operation mode.")
- ui.safe_log("Submit inputs normally instead.")
+ ui.safe_log(string("commands.say_redundant"))
+ ui.safe_log(string("commands.say_submit_normally"))
return
if ui.celune.config.get("ipa") is False:
@@ -467,12 +692,12 @@ def worker() -> None:
safe_log_dev = getattr(ui, "safe_log_dev", None)
if callable(safe_log_dev):
safe_log_dev(
- f"Found {unmatched} unmatched IPA characters, output may be inaccurate.",
+ string("commands.unmatched_ipa", count=unmatched),
"warning",
)
else:
ui.safe_log(
- f"Found {unmatched} unmatched IPA characters, output may be inaccurate.",
+ string("commands.unmatched_ipa", count=unmatched),
"warning",
)
@@ -485,38 +710,36 @@ def worker() -> None:
if not args:
ui.celune.backend.current_seed = None
ui.celune.backend.random_seed = True
- ui.safe_log("Custom seed removed.")
+ ui.safe_log(string("commands.custom_seed_removed"))
return
if args[0].lower() in ["random", "unset", "none", "off"]:
ui.celune.backend.current_seed = None
ui.celune.backend.random_seed = True
- ui.safe_log("Custom seed removed.")
+ ui.safe_log(string("commands.custom_seed_removed"))
return
try:
value = int(args[0])
except ValueError:
- ui.safe_log(f"Invalid argument: {args[0]}", "warning")
+ ui.safe_log(string("commands.invalid_argument", value=args[0]), "warning")
return
if not 0 <= value < 2**32:
- ui.safe_log(f"Seed must be between 0 and {2**32 - 1}.", "warning")
+ ui.safe_log(string("commands.seed_range", max_value=2**32 - 1), "warning")
return
ui.celune.backend.current_seed = value
ui.celune.backend.random_seed = False
- ui.safe_log(f"Seed set to {value}.")
+ ui.safe_log(string("commands.seed_set", value=value))
return
if command == "tutorial":
- ui.safe_log(
- f"Tutorial activated. Listen to what's said to learn how to use {APP_NAME}."
- )
+ ui.safe_log(string("commands.tutorial_activated", app_name=APP_NAME))
tutorial(ui)
return
if command == "stop":
if not ui.celune.force_stop_speech():
- ui.safe_log("Nothing to stop.")
+ ui.safe_log(string("commands.nothing_to_stop"))
return
return
@@ -524,6 +747,4 @@ def worker() -> None:
ui.graceful_exit()
return
- ui.safe_log(
- f"Unknown command: {command}. Run /help for a list of commands.", "warning"
- )
+ ui.safe_log(string("commands.unknown_command", command=command), "warning")
diff --git a/celune/ui/headless.py b/celune/ui/headless.py
index 45be255..cc5f187 100644
--- a/celune/ui/headless.py
+++ b/celune/ui/headless.py
@@ -13,6 +13,7 @@
from ..utils import discard
from ..config import Config, config_bool
from ..constants import APP_NAME, SIGTSTP
+from ..i18n import string
class CeluneHeadlessUI:
@@ -78,9 +79,9 @@ def headless_log(self, msg: str, severity: str = "info") -> None:
"""
prefix = ""
if severity == "warning":
- prefix = "[WARN] "
+ prefix = string("headless.warn_prefix")
elif severity == "error":
- prefix = "[ERROR] "
+ prefix = string("headless.error_prefix")
print(f"{prefix}{self.severity_color(severity)}{msg}{self.reset}", flush=True)
def headless_error(self, error: str) -> None:
@@ -95,7 +96,11 @@ def run(self) -> None:
"""Start the headless interface."""
if not self._has_celune():
warnings.warn(
- f"{self.__class__.__name__} has no attached {APP_NAME} instance: this will do nothing",
+ string(
+ "headless.no_attached_instance",
+ class_name=self.__class__.__name__,
+ app_name=APP_NAME,
+ ),
RuntimeWarning,
)
diff --git a/celune/ui/resources.py b/celune/ui/resources.py
index 0f15537..035aef6 100644
--- a/celune/ui/resources.py
+++ b/celune/ui/resources.py
@@ -11,6 +11,7 @@
import torch
import psutil
+from ..i18n import string
from ..utils import celune_day_status, lunar_info, lunar_phase
if TYPE_CHECKING:
@@ -19,6 +20,7 @@
_NVIDIA_SMI: Optional[str] = shutil.which("nvidia-smi")
_NVIDIA_SMI_PROC: Optional[subprocess.Popen[str]] = None
_NVIDIA_SMI_USAGE: Optional[int] = None
+FOOTER_ROTATE_SECONDS = 2.06
def format_vram() -> str:
@@ -154,6 +156,9 @@ def resource_pages(celune: Celune, theme_name: Optional[str] = None) -> tuple[st
pages.append("/help commands")
if celune is not None:
+ if getattr(celune, "input_mode", "text_to_speech") == "voice_conversion":
+ pages.append(string("ui.footer_toggle_recording"))
+
active_theme = theme_name
enter_action = "skip" if celune.is_in_tutorial else "say"
diff --git a/celune/ui/terminal.py b/celune/ui/terminal.py
index 8862549..094e651 100644
--- a/celune/ui/terminal.py
+++ b/celune/ui/terminal.py
@@ -1,9 +1,9 @@
# SPDX-License-Identifier: MIT
"""Terminal UI helpers."""
-import sys
-import re
import logging
+import re
+import sys
from typing import Callable, Optional
from collections.abc import Collection
@@ -94,6 +94,43 @@ def __init__(
filter_messages # these messages will be filtered out by the logger
)
+ def _is_filtered_message(self, message: str) -> bool:
+ """Return whether one redirected message should be suppressed."""
+ if self.filter_messages is None:
+ return False
+
+ return any(
+ filtered_message in message for filtered_message in self.filter_messages
+ )
+
+ @staticmethod
+ def _severity_for_message(message: str, default_severity: str) -> str:
+ """Infer log severity from one redirected text line."""
+ lowered = message.casefold()
+
+ if "[error]" in lowered:
+ return "error"
+ if "[warning]" in lowered:
+ return "warning"
+ if "traceback (most recent call last):" in lowered:
+ return "error"
+ if re.search(
+ r"\b(?:error|exception|fatal(?: error)?)\b",
+ lowered,
+ ):
+ return "error"
+ if re.search(
+ (
+ r"\b(?:warning|futurewarning|deprecationwarning|"
+ r"pendingdeprecationwarning|runtimewarning|resourcewarning|"
+ r"userwarning|syntaxwarning|importwarning|unicodewarning|"
+ r"byteswarning)\b"
+ ),
+ lowered,
+ ):
+ return "warning"
+ return default_severity
+
def write(self, text: str) -> None:
"""Write text to the logger.
@@ -103,10 +140,6 @@ def write(self, text: str) -> None:
if not text:
return
- if self.filter_messages is not None:
- if text in self.filter_messages:
- return
-
# strip any incoming ANSI, but keep TTY specific input
ansi_regex = re.compile(
r"\x1b(?:\[[0-?]*[ -/]*[@-~]|][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])"
@@ -123,8 +156,11 @@ def write(self, text: str) -> None:
chunk = self._buffer[:pos].strip()
self._buffer = self._buffer[pos + 1 :]
- if chunk:
- self.write_callback(chunk, self.default_severity)
+ if chunk and not self._is_filtered_message(chunk):
+ self.write_callback(
+ chunk,
+ self._severity_for_message(chunk, self.default_severity),
+ )
def ansi(self, escape: str) -> None:
"""Write ANSI escape code(s) to the terminal directly.
@@ -139,11 +175,17 @@ def ansi(self, escape: str) -> None:
if any_ansi:
escapes = "".join(any_ansi)
self.underlying_stdout.write(escapes)
+ self.underlying_stdout.flush()
def flush(self) -> None:
"""Flush the buffers."""
if self._buffer.strip():
- self.write_callback(self._buffer.strip(), self.default_severity)
+ chunk = self._buffer.strip()
+ if not self._is_filtered_message(chunk):
+ self.write_callback(
+ chunk,
+ self._severity_for_message(chunk, self.default_severity),
+ )
self._buffer = ""
def isatty(self) -> bool:
@@ -158,9 +200,23 @@ def isatty(self) -> bool:
class UILogHandler(logging.Handler):
"""Route Python logging records into Celune's UI log callback."""
- def __init__(self, write_callback: Callable[[str, str], None]) -> None:
+ def __init__(
+ self,
+ write_callback: Callable[[str, str], None],
+ filter_messages: Optional[Collection[str]] = None,
+ ) -> None:
super().__init__()
self.write_callback = write_callback
+ self.filter_messages = filter_messages
+
+ def _is_filtered_message(self, message: str) -> bool:
+ """Return whether one logging message should be suppressed."""
+ if self.filter_messages is None:
+ return False
+
+ return any(
+ filtered_message in message for filtered_message in self.filter_messages
+ )
def emit(self, record: logging.LogRecord) -> None:
"""Forward one Python logging record into the UI log stream.
@@ -183,6 +239,9 @@ def emit(self, record: logging.LogRecord) -> None:
):
message = "triton not found; flop counting will not work for triton kernels"
+ if self._is_filtered_message(message):
+ return
+
if record.levelno >= logging.ERROR:
severity = "error"
prefix = "Internal runtime error:"
@@ -194,3 +253,15 @@ def emit(self, record: logging.LogRecord) -> None:
prefix = "Internal runtime notice:"
self.write_callback(" ".join([prefix, message]), severity)
+
+
+def is_celune_log_record(record: logging.LogRecord) -> bool:
+ """Return whether a logging record belongs to Celune itself.
+
+ Args:
+ record: The logging record to classify.
+
+ Returns:
+ bool: ``True`` when the record originated from Celune loggers.
+ """
+ return record.name == "celune" or record.name.startswith("celune.")
diff --git a/celune/ui/theme.py b/celune/ui/theme.py
index fb0a695..172fe99 100644
--- a/celune/ui/theme.py
+++ b/celune/ui/theme.py
@@ -42,7 +42,7 @@
border: round $primary;
}
- #style {
+ #style, #vc-mode, #vc-pitch {
width: 14;
height: 3;
border: round $primary;
@@ -65,7 +65,7 @@
color: $secondary;
}
- #style:focus, #style:hover, #style.-active, #input:hover {
+ #style:focus, #style:hover, #style.-active, #vc-mode:focus, #vc-mode:hover, #vc-mode.-active, #vc-pitch:focus, #vc-pitch:hover, #vc-pitch.-active, #input:hover {
border: round $foreground;
background: $background;
background-tint: transparent;
diff --git a/celune/updater.py b/celune/updater.py
index 4385784..1b8d935 100644
--- a/celune/updater.py
+++ b/celune/updater.py
@@ -3,26 +3,26 @@
from __future__ import annotations
-import ctypes
-import hashlib
-import json
import os
import re
-import shutil
-import subprocess
import sys
-import tempfile
+import json
import time
-import urllib.request
+import ctypes
+import shutil
+import hashlib
import zipfile
-from dataclasses import dataclass
+import tempfile
+import subprocess
+import urllib.request
from pathlib import Path
+from dataclasses import dataclass
from typing import Optional, Union
from . import __version__
from .exceptions import UpdateError
-from .paths import project_root, running_compiled
from .typing.common import JSONSerializable
+from .paths import project_root, running_compiled
REMOTE_URL = "https://github.com/celunah/celune.git"
ARTIFACT_BASE_URL = "https://nightly.link/celunah/celune/workflows/ci"
diff --git a/celune/utils.py b/celune/utils.py
index 48b26e2..51d0d00 100644
--- a/celune/utils.py
+++ b/celune/utils.py
@@ -14,15 +14,15 @@
import multiprocessing
from pathlib import Path
from collections.abc import Iterator
-from typing import Union, Callable, Optional, Literal, Any, overload
+from typing import Union, Callable, Optional, Literal, Any, TextIO, overload
import psutil
import langdetect
from .paths import traceback_path
from .constants import REFERENCE_NEW_MOON
-from .terminal import supports_ansi as terminal_supports_ansi
from .typing.utils import CallerInfo, LanguageResult
+from .terminal import supports_ansi as terminal_supports_ansi
def get_revision() -> str:
@@ -255,13 +255,16 @@ def run_async(
return proc
-def supports_ansi() -> bool:
+def supports_ansi(stream: Optional[TextIO] = None) -> bool:
"""Does the terminal support ANSI color codes?
+ Args:
+ stream: The stream to check ANSI capabilities of.
+
Returns:
- bool: Whether the terminal supports ANSI color codes.
+ bool: Whether the current terminal or specified stream supports ANSI color codes.
"""
- return terminal_supports_ansi()
+ return terminal_supports_ansi(stream)
def format_error(e: Exception, dev: bool) -> str:
@@ -831,10 +834,11 @@ def normalize_special_characters(text: str) -> str:
"\u201e": '"', # double low quote
"\u2018": "'", # left single quote
"\u2019": "'", # right single quote
- "\u2013": "-", # en dash
- "\u2014": "-", # em dash
+ "\u2013": " - ", # en dash
+ "\u2014": " - ", # em dash
"\u2026": "...", # ellipsis
}
)
- return text.translate(special_char_mappings)
+ normalized = text.translate(special_char_mappings)
+ return re.sub(r"\s{2,}", " ", normalized)
diff --git a/celune/vram.py b/celune/vram.py
index ab09e94..d4e2ac0 100644
--- a/celune/vram.py
+++ b/celune/vram.py
@@ -3,24 +3,23 @@
import math
from dataclasses import dataclass
-from collections.abc import Mapping
from typing import Optional, cast
+from collections.abc import Mapping
import torch
-from .constants import JSONSerializable, VRAM_REQUIREMENTS, TIERS
from .typing.common import VramTier
+from .constants import JSONSerializable, VRAM_REQUIREMENTS, TIERS
QWEN3_0_6B_MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
QWEN3_1_7B_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
-# fake is only included to satisfy the test suite
-# it is not a real backend
+TEST_BACKENDS = ("fake", "counting")
BACKENDS_ALLOWED: Mapping[VramTier, list[str]] = {
- "low": ["mini", "qwen3", "fake"],
- "medium": ["mini", "qwen3", "fake"],
- "high": ["mini", "qwen3", "dotstts", "voxcpm2", "fake"],
- "xhigh": ["mini", "qwen3", "dotstts", "voxcpm2", "fake"],
+ "low": ["mini", "qwen3", *TEST_BACKENDS],
+ "medium": ["mini", "qwen3", *TEST_BACKENDS],
+ "high": ["mini", "qwen3", "dotstts", "voxcpm2", *TEST_BACKENDS],
+ "xhigh": ["mini", "qwen3", "dotstts", "voxcpm2", *TEST_BACKENDS],
}
diff --git a/default_config.yaml b/default_config.yaml
index 4a6e731..5dd6ed7 100644
--- a/default_config.yaml
+++ b/default_config.yaml
@@ -1,4 +1,4 @@
-# TTS backend
+# backend
# you will be asked to choose, but VRAM presets may limit availibility
# of certain backends
#
@@ -6,6 +6,7 @@
# dotstts = slower, very high quality
# voxcpm2 = slower, high quality
# mini = much faster, and just as good
+# seed-vc = VC backend, converts speech rather than generating it
backend: null
# Qwen3 TTS settings
@@ -16,6 +17,8 @@ qwen3_x_vector_only: true
# CEVOICE voice selection
#
# default voices are in the "default" pack and are usable for most
+# check scripts\cac.py to create your own custom voice pack by following
+# step by step instructions
voice_bundle: default
# developer mode
@@ -24,6 +27,24 @@ voice_bundle: default
# and auto-voice analysis upon end of utterance
dev: false
+# voice conversion default settings
+#
+# positive values raise the converted voice, negative values lower it
+# useful for helping cross-gender conversions land more naturally
+voice_conversion_pitch_shift: 0
+# false = talk mode, true = sing mode
+voice_conversion_f0_condition: false
+
+# audio device settings
+#
+# set either key to a sounddevice device name or numeric index
+# null = use the current system default device
+#
+# if the specified device name has several matches, Celune will list the possible device
+# entries and request you to select one and restart the application after selecting it
+input_device: null
+output_device: null
+
# headless mode / Celune embedded framework
#
# this will disable the UI and only API and extensions can make
diff --git a/extensions/test.py b/extensions/test.py
index 67d09e1..1d8a32b 100644
--- a/extensions/test.py
+++ b/extensions/test.py
@@ -4,17 +4,26 @@
import time
from pathlib import Path
+import celune
from celune import CeluneExtension
+from celune.dataclasses.events import ReadyEvent, VoiceChangedEvent
+from celune.utils import discard
class TestExtension(CeluneExtension):
"""A sample Celune extension showcasing all features available in Celune's extension context."""
EXTENSION_NAME = "Test"
- AUTOSTART = False # if you want Celune to load this demo, set it to True
- def autostart(self) -> None:
- """Demonstrate extension behavior during autostart."""
+ @celune.subscribe("ready", enabled=False)
+ def on_ready(self, event: ReadyEvent) -> None:
+ """Demonstrate extension behavior when Celune becomes ready.
+
+ Args:
+ event: Ready event emitted after Celune finishes startup.
+ """
+ discard(event)
+
self.log("Log test")
time.sleep(1) # due to threading, this does not block
self.status("Status test")
@@ -40,6 +49,15 @@ def autostart(self) -> None:
# Celune can ignore saving artifacts from self.say()
self.say("You will only hear this once.", save=False)
+ @celune.subscribe("voice_changed")
+ def on_voice_changed(self, event: VoiceChangedEvent) -> None:
+ """Demonstrate access to typed voice-change event payloads.
+
+ Args:
+ event: Voice-change event carrying the previous and new voice names.
+ """
+ self.log(f"Voice changed from {event.old_voice} to {event.new_voice}.")
+
def invoke(self) -> None:
"""Demonstrate manual extension invocation behavior."""
self.log("You invoked the extension.")
diff --git a/launcher.c b/launcher.c
index 37518e1..6594d41 100644
--- a/launcher.c
+++ b/launcher.c
@@ -457,7 +457,7 @@ int run_unix(int argc, char **argv) {
int exit_code = WEXITSTATUS(status);
if (exit_code == EXIT_PENDING_UPDATE) {
if (!spawn_update_helper_unix(python, main_py, launcher, repo_root, argc, argv)) {
- printfe("Celune could not start its update helper.\n");
+ printfe("Celune could not start her update helper.\n");
return 1;
}
return 0;
@@ -482,7 +482,7 @@ int run_unix(int argc, char **argv) {
if (access(setup_py, R_OK) != 0) {
printfe("Python virtual environment and/or interpreter was not found or isn't working.\n");
- printfe("Celune needs setup.py to create its virtual environment.\n");
+ printfe("Celune needs setup.py to create her virtual environment.\n");
return 1;
}
@@ -615,7 +615,7 @@ int run_windows(int argc, char **argv) {
}
if (!file_exists(target)) {
- printfe("Celune could not find its compiled runtime binary.\n");
+ printfe("Celune could not find her compiled runtime binary.\n");
printfe("Expected file: %s\n", target);
return 1;
}
@@ -672,7 +672,7 @@ int run_windows(int argc, char **argv) {
setuptools_vendor
);
if (nuitka_pythonpath_len < 0 || (size_t)nuitka_pythonpath_len >= sizeof(nuitka_pythonpath)) {
- printfe("Celune cannot set up its Python path, the path is too long.\n");
+ printfe("Celune cannot set up her Python path, the path is too long.\n");
return 1;
}
@@ -684,14 +684,14 @@ int run_windows(int argc, char **argv) {
char path_value[5200];
int updated_path_len = snprintf(path_value, sizeof(path_value), "%s;%s", python_home, updated_path);
if (updated_path_len < 0 || (size_t)updated_path_len >= sizeof(path_value)) {
- printfe("Celune cannot set up PATH, the path is too long.\n");
+ printfe("Celune cannot set up %%PATH%%, the path is too long.\n");
return 1;
}
if (!SetEnvironmentVariableA("PATH", path_value) ||
!SetEnvironmentVariableA("PYTHONHOME", python_home) ||
!SetEnvironmentVariableA("NUITKA_PYTHONPATH", nuitka_pythonpath)) {
- printfe("Celune could not configure its Python runtime environment.\n");
+ printfe("Celune could not configure her Python runtime environment.\n");
return 1;
}
@@ -733,7 +733,7 @@ int run_windows(int argc, char **argv) {
);
if (!ok) {
- printfe("Celune could not launch its compiled runtime.\n%lu\n", GetLastError());
+ printfe("Celune could not launch her compiled runtime.\nExit code: %lu\n", GetLastError());
return 1;
}
@@ -751,7 +751,7 @@ int run_windows(int argc, char **argv) {
return 1;
}
if (!spawn_update_helper_windows(venv_python, main_py, launcher, repo_root, argc, argv)) {
- printfe("Celune could not start its update helper.\n");
+ printfe("Celune could not start her update helper.\n");
return 1;
}
return 0;
diff --git a/pyproject.toml b/pyproject.toml
index d592ca2..aa012bb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "celune"
-version = "4.1.0"
+version = "4.2.0"
description = "Real-time AI TTS character engine with expressive voices and high-quality playback"
readme = "README.md"
requires-python = ">=3.12,<3.14"
@@ -16,19 +16,6 @@ dependencies = [
# This package is not required if you want to use Persona in text-only mode,
# or Celune operates in speech-only mode.
"torchvision==0.26.0+cu128",
- # Celune sounds best with faster-qwen3-tts 0.2.4.
- # To match this version's behavior in 0.2.5 or newer, you may need to set non_streaming_mode=True.
- # This parameter is ambiguously named, it doesn't actually disable streaming mode.
- # If it did, Celune would have a problem operating.
- "faster-qwen3-tts>=0.2.4",
- # If you want Celune to use the alternative VoxCPM2-based backend, install this package.
- # This backend may not be available on some VRAM presets. Refer to Celune's default configuration for details.
- "voxcpm>=2.0.2",
- # dots.tts is installed from Celunah's Windows-safe fork at this time.
- "dots.tts @ git+https://github.com/celunah/dots.tts",
- # Pocket TTS is a new backend added in version 4.0.0, that allows Celune to sound the part and use up very little
- # memory, just under 200 MB!
- "pocket-tts>=2.1.0",
# If not installed, Hugging Face falls back to the older Git LFS way of downloading models.
"hf-xet",
"accelerate",
@@ -81,7 +68,7 @@ Issues = "https://github.com/celunah/celune/issues"
# Why does CodeRabbit think I'm uploading Celune to PyPI?
# Her package is not standalone.
[tool.setuptools.package-data]
-celune = ["assets/*.wav"]
+celune = ["assets/*.wav", "lang/*.json"]
[tool.setuptools.data-files]
voices = ["voices/*.cevoice"]
@@ -100,16 +87,20 @@ explicit = true
[dependency-groups]
# Install to run CI workflows.
dev = [
+ # Is there any faster unit test-specific library to use here?
"pytest",
+ # "I really hate pyright." - she says.
"pyrefly",
"pylint",
+ # Docstrings matter, so everyone can read the code more easily.
"docstr-coverage",
"ruff",
+ # This package is required to compile Celune into a properly packaged binary, bypassing the need to run Python.
+ "nuitka>=4.1.2",
# If not installed, type checkers may complain.
"types-pyyaml",
"types-psutil",
"types-tqdm",
- "nuitka>=4.1.2",
]
[project.optional-dependencies]
@@ -121,9 +112,57 @@ api = [
"pydantic",
]
+# Which backend do you want to choose? Choose and install any of the following optional dependency groups.
+# You need at least one of these to start Celune.
+# Try `uv sync --extra ` or `uv sync --all-extras` to install all backends.
+#
+# The original Qwen-based classic Celune backend. Balanced sound quality and speed.
+qwen3 = [
+ # Celune sounds best with faster-qwen3-tts 0.2.4.
+ # To match this version's behavior in 0.2.5 or newer, you may need to set non_streaming_mode=True.
+ # This parameter is ambiguously named, it doesn't actually disable streaming mode.
+ # If it did, Celune would have a problem operating.
+ "faster-qwen3-tts>=0.2.4",
+
+]
+
+# A high-end diffusion based Celune backend with high fidelity and expressiveness. Slower, but way more expressive.
+voxcpm2 = [
+ # Celune requires a streaming-capable version of the voxcpm library. Versions newer than 1.5.0 support it, however
+ # VoxCPM2 may require at least version 2.0.0.
+ "voxcpm>=2.0.0",
+]
+
+# An alternative diffusion based Celune backend with high fidelity and speaker similarity.
+# Slowest, but very speaker accurate.
+dotstts = [
+ # Celune's dots.tts backend uses a forked version that removes an unnecessary requirement that may
+ # fail to build on Windows systems. Do not try to use the upstream package.
+ "dots.tts @ git+https://github.com/celunah/dots.tts",
+]
+
+# A cheap on compute Celune backend providing an exceptional sound quality. Fastest, with an exceptional sound quality
+# to size ratio.
+mini = [
+ "pocket-tts>=2.1.0",
+]
+
+# A voice conversion backend allowing you to speak in your character voices directly. It does not use TTS, using it
+# will disable TTS features.
+seed-vc = [
+ # Celune's SeedVC backend uses a forked version that turns hard pins into "this or newer", allowing Celune
+ # to resolve it under NumPy v2. Do not try to use the upstream package, it uses an unsupported version of NumPy.
+ "seed-vc @ git+https://github.com/celunah/seed-vc.git",
+]
+
[tool.pytest.ini_options]
filterwarnings = [
- "ignore:builtin type SwigPyPacked has no __module__ attribute:DeprecationWarning",
- "ignore:builtin type SwigPyObject has no __module__ attribute:DeprecationWarning",
- "ignore:builtin type swigvarlink has no __module__ attribute:DeprecationWarning",
+ # 'sentencepiece' imports cause these warnings, this package is used by Celune Mini.
+ "ignore:builtin type SwigPyPacked has no __module__ attribute:DeprecationWarning",
+ "ignore:builtin type SwigPyObject has no __module__ attribute:DeprecationWarning",
+ "ignore:builtin type swigvarlink has no __module__ attribute:DeprecationWarning",
+ # Celune's deprecated extension syntax is being exercised in the test suite, causing these warnings.
+ # This syntax will be removed soon.
+ "ignore:CeluneExtension\\.autostart\\(\\) is deprecated, please use @celune\\.subscribe\\('ready'\\) instead:DeprecationWarning",
+ "ignore:CeluneExtensionManager\\.autostart_all\\(\\) is deprecated, please use @celune\\.subscribe\\('ready'\\) in your extensions instead:DeprecationWarning",
]
diff --git a/requirements.txt b/requirements.txt
index 6745ed6..7f7c2e9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -23,6 +23,7 @@ dots.tts @ git+https://github.com/celunah/dots.tts
pocket-tts>=2.1.0
# If not installed, Hugging Face falls back to the older Git LFS way of downloading models.
hf-xet
+safetensors
# Qwen3 does not support newer versions of Hugging Face libraries at this time.
# Celune will use Transformers 4.x for the time being.
huggingface-hub<1.0.0
diff --git a/scripts/build_nuitka.ps1 b/scripts/build_nuitka.ps1
index e71da65..92803ae 100644
--- a/scripts/build_nuitka.ps1
+++ b/scripts/build_nuitka.ps1
@@ -8,10 +8,16 @@ $launcherSource = Join-Path $repoRoot "launcher.c"
$launcherRes = Join-Path $repoRoot "resources\celune.res"
$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe"
$projectVersion = Select-String -Path (Join-Path $repoRoot "pyproject.toml") -Pattern '^version = "([^"]+)"' | Select-Object -First 1
+$existingProcesses = Get-Process -Name @("celune", "celune-bin") -ErrorAction SilentlyContinue
$env:CL = "/O2 /GL /GS /guard:cf /DNDEBUG"
$env:_CL_ = "/link /LTCG /OPT:REF /OPT:ICF /DYNAMICBASE /NXCOMPAT"
+if ($null -ne $existingProcesses) {
+ Write-Host "Celune is already running, terminating before proceeding with build."
+ $existingProcesses | Stop-Process -Force -ErrorAction SilentlyContinue
+}
+
if ($null -eq $projectVersion) {
throw "Could not determine the project version from pyproject.toml."
}
diff --git a/scripts/build_nuitka.sh b/scripts/build_nuitka.sh
index ad3fc50..7873c61 100755
--- a/scripts/build_nuitka.sh
+++ b/scripts/build_nuitka.sh
@@ -11,6 +11,15 @@ app_dir="$output_dir/Celune.AppDir"
desktop_src="$repo_root/Celune.AppDir/celune.desktop"
icon_src="$repo_root/Celune.AppDir/celune.png"
+if pgrep -x celune >/dev/null || pgrep -x celune-bin >/dev/null; then
+ echo "Celune is already running, terminating before proceeding with build."
+ pkill -TERM -x celune || true
+ pkill -TERM -x celune-bin || true
+ sleep 1
+ pkill -KILL -x celune || true
+ pkill -KILL -x celune-bin || true
+fi
+
version_line="$(grep -m1 '^version = "' "$repo_root/pyproject.toml")"
if [[ -z "$version_line" ]]; then
echo "Could not determine the project version from pyproject.toml." >&2
diff --git a/scripts/run_ci.py b/scripts/run_ci.py
index 6d9abfa..bee5576 100644
--- a/scripts/run_ci.py
+++ b/scripts/run_ci.py
@@ -3,6 +3,7 @@
import sys
import subprocess
+from typing import Optional
from tqdm.contrib import tzip
@@ -18,13 +19,22 @@
cmds_failed = 0
total_errors = []
-_CACHE_PERMISSION_MARKERS = (
- "Access is denied.",
+_AGENT_ERROR_MARKERS = (
"Access is denied",
"Permission denied",
+ "Operation not permitted",
)
+def _agent_permission_marker(output: str) -> Optional[str]:
+ """Return the first permission marker found in command output."""
+ normalized_output = output.lower()
+ for marker in _AGENT_ERROR_MARKERS:
+ if marker.lower() in normalized_output:
+ return marker
+ return None
+
+
def _run_uv_command(*cmd: str) -> None:
"""Run one uv-backed CI command, retrying without cache on permission errors."""
base_cmd = ["uv", "run", *cmd]
@@ -40,17 +50,27 @@ def _run_uv_command(*cmd: str) -> None:
return
except subprocess.CalledProcessError as failed:
combined_output = f"{failed.stdout}\n{failed.stderr}"
- if not any(marker in combined_output for marker in _CACHE_PERMISSION_MARKERS):
+ marker = _agent_permission_marker(combined_output)
+ if marker is None:
raise
- subprocess.run(
- ["uv", "--no-cache", "run", *cmd],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- check=True,
- text=True,
- timeout=300,
- )
+ try:
+ subprocess.run(
+ ["uv", "--no-cache", "run", *cmd],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=True,
+ text=True,
+ timeout=300,
+ )
+ except subprocess.CalledProcessError as failed:
+ combined_output = f"{failed.stdout}\n{failed.stderr}"
+ marker = _agent_permission_marker(combined_output)
+ if marker is not None:
+ raise RuntimeError(
+ f"agent has no permissions to run CI: {marker}"
+ ) from failed
+ raise
if len(CI_COMMANDS) != len(CI_PATHS):
diff --git a/tests/support.py b/tests/support.py
index a2ab7df..aabb372 100644
--- a/tests/support.py
+++ b/tests/support.py
@@ -18,8 +18,10 @@
import numpy.typing as npt
from celune.utils import discard
-from celune.backends.base import CeluneBackend
+from celune.backends.tts.base import CeluneBackend
+from celune.backends.vc.base import CeluneVCBackend
from celune.constants import JSONSerializable, PipelineStates
+from celune.dataclasses.pipeline import AudioOutput, VoiceConversionRequest
if TYPE_CHECKING:
from celune.celune import Celune
@@ -40,6 +42,7 @@ class FakeBackend(CeluneBackend):
supported_languages = ("en",)
voice_models = {"balanced": "fake/balanced", "bold": "fake/bold"}
default_voice = "balanced"
+ is_fake = True
def model_is_available_locally(
self, model: str, lang: Optional[str] = None
@@ -84,10 +87,32 @@ def generate_stream(
Returns:
Iterator[tuple[npt.NDArray[np.float32], int, dict[str, int]]]: An iterator yielding one fake audio chunk.
"""
- del model, kwargs
+ discard(model)
+ discard(kwargs)
yield np.zeros((8, 2), dtype=np.float32), 48000, {"chunk_steps": 2}
+class FakeVCBackend(CeluneVCBackend):
+ """Tiny VC backend implementation used by tests without model work."""
+
+ name = "fake-vc"
+
+ def convert(self, request: VoiceConversionRequest) -> AudioOutput:
+ """Return the source audio unchanged for one voice-conversion request.
+
+ Args:
+ request: The voice-conversion request under test.
+
+ Returns:
+ AudioOutput: Playable audio copied from the request payload.
+ """
+ return AudioOutput(
+ audio=np.asarray(request.source_audio, dtype=np.float32).copy(),
+ sample_rate=request.sample_rate,
+ label=request.label,
+ )
+
+
class FakeGlow:
"""Minimal RGB glow fake that records lifecycle calls."""
@@ -214,8 +239,12 @@ def make_pipeline_engine() -> SimpleNamespace:
progress: list[tuple[Optional[float], Optional[float]]] = []
engine = SimpleNamespace()
engine.backend = SimpleNamespace(supported_languages=("en",))
+ engine.vc_backend = None
engine.config = {}
+ engine.input_mode = "text_to_speech"
engine.language = "Auto"
+ engine.current_voice = "balanced"
+ engine.current_character = None
engine.persona_attachments = []
engine.persona_recent_visual_context = ()
engine.use_normalization = False
@@ -298,7 +327,7 @@ class StubQwen3TTS:
)
},
):
- qwen3 = importlib.import_module("celune.backends.qwen3")
+ qwen3 = importlib.import_module("celune.backends.tts.qwen3")
yield qwen3.Qwen3
@@ -313,7 +342,7 @@ class StubVoxCPM:
sys.modules,
{"voxcpm": SimpleNamespace(VoxCPM=StubVoxCPM)},
):
- voxcpm2 = importlib.import_module("celune.backends.voxcpm2")
+ voxcpm2 = importlib.import_module("celune.backends.tts.voxcpm2")
yield voxcpm2.VoxCPM2
@@ -337,7 +366,7 @@ class StubDotsTtsRuntime:
"dots_tts.runtime": runtime_module,
},
):
- dotstts = importlib.import_module("celune.backends.dotstts")
+ dotstts = importlib.import_module("celune.backends.tts.dotstts")
yield dotstts.DotsTtsMF
@@ -352,5 +381,5 @@ class StubTTSModel:
sys.modules,
{"pocket_tts": SimpleNamespace(TTSModel=StubTTSModel)},
):
- mini = importlib.import_module("celune.backends.mini")
+ mini = importlib.import_module("celune.backends.tts.mini")
yield mini.Mini
diff --git a/tests/test_api_audio.py b/tests/test_api_audio.py
index 643b7ce..96b4be7 100644
--- a/tests/test_api_audio.py
+++ b/tests/test_api_audio.py
@@ -5,12 +5,15 @@
import json
import time
import queue
-from unittest import TestCase
+import asyncio
+from unittest import TestCase, mock
from types import SimpleNamespace
from typing import cast, Optional
import numpy as np
import soundfile as sf
+from fastapi import UploadFile
+from fastapi.responses import JSONResponse, StreamingResponse
from starlette.responses import Response
from celune import api
@@ -21,6 +24,30 @@
class ApiAudioTests(TestCase):
"""Tests for API audio payload formatting."""
+ @staticmethod
+ def _wav_bytes(
+ audio: np.ndarray,
+ sample_rate: int = 24000,
+ ) -> bytes:
+ """Encode one in-memory WAV fixture for upload tests."""
+ buffer = io.BytesIO()
+ sf.write(buffer, audio, sample_rate, format="WAV")
+ return buffer.getvalue()
+
+ @staticmethod
+ async def _response_bytes(response: Response) -> bytes:
+ """Collect one response body for direct route-call tests."""
+ if getattr(response, "body", None) is not None:
+ return bytes(response.body)
+
+ chunks: list[bytes] = []
+ async for chunk in cast(StreamingResponse, response).body_iterator:
+ if isinstance(chunk, str):
+ chunks.append(chunk.encode("utf-8"))
+ else:
+ chunks.append(bytes(chunk))
+ return b"".join(chunks)
+
def test_audio_bytes_encode_flac_from_stream_chunks(self) -> None:
"""Verify queued speech audio is returned as PCM24 FLAC."""
chunks: SpeechStreamQueue = queue.Queue()
@@ -103,3 +130,76 @@ def test_speech_job_lookup_expires_old_jobs(self) -> None:
finally:
api.speech_job_ttl_seconds = previous_ttl
api.speech_jobs.clear()
+
+ def test_convert_route_rejects_requests_outside_voice_conversion_mode(self) -> None:
+ """Verify VC conversion uploads are rejected while running in TTS mode."""
+ previous_celune = api.bound_celune
+ audio = np.zeros((24, 2), dtype=np.float32)
+
+ try:
+ api.bound_celune = cast(
+ Celune,
+ SimpleNamespace(
+ input_mode="text_to_speech",
+ dev=False,
+ ),
+ )
+ response = asyncio.run(
+ api.convert_audio(
+ UploadFile(
+ file=io.BytesIO(self._wav_bytes(audio)),
+ filename="fixture.wav",
+ )
+ )
+ )
+ finally:
+ api.bound_celune = previous_celune
+
+ self.assertEqual(response.status_code, 409)
+ payload = json.loads(bytes(cast(JSONResponse, response).body))
+ self.assertIn("I am not currently able", payload["message"])
+
+ def test_convert_route_returns_converted_audio(self) -> None:
+ """Verify VC conversion uploads return FLAC audio in Celune's playback format."""
+ previous_celune = api.bound_celune
+ source_audio = np.zeros((24, 2), dtype=np.float32)
+ converted_audio = np.ones((12, 2), dtype=np.float32) * 0.25
+
+ try:
+ api.bound_celune = cast(
+ Celune,
+ SimpleNamespace(
+ input_mode="voice_conversion",
+ dev=False,
+ convert_audio=mock.Mock(
+ return_value=SimpleNamespace(
+ audio=converted_audio,
+ sample_rate=24000,
+ label="fixture.wav",
+ )
+ ),
+ ),
+ )
+ response = asyncio.run(
+ api.convert_audio(
+ UploadFile(
+ file=io.BytesIO(
+ self._wav_bytes(source_audio, sample_rate=44100)
+ ),
+ filename="fixture.wav",
+ )
+ )
+ )
+ finally:
+ api.bound_celune = previous_celune
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.headers["x-sample-rate"], "48000")
+ payload = asyncio.run(self._response_bytes(response))
+ self.assertEqual(payload[:4], b"fLaC")
+ decoded_audio, sample_rate = sf.read(
+ io.BytesIO(payload),
+ dtype="float32",
+ )
+ self.assertEqual(sample_rate, 48000)
+ self.assertEqual(decoded_audio.shape, (24, 2))
diff --git a/tests/test_api_webui.py b/tests/test_api_webui.py
index 1e52ec5..3eb5579 100644
--- a/tests/test_api_webui.py
+++ b/tests/test_api_webui.py
@@ -3,14 +3,17 @@
from types import SimpleNamespace
from queue import Queue
+import asyncio
from typing import cast
from unittest import TestCase, mock
import numpy as np
from starlette.requests import Request
+from starlette.responses import Response
from celune import api
from celune.celune import Celune
+from celune.i18n import string
from celune.pipeline import SpeechStreamQueue
@@ -31,8 +34,10 @@ def setUp(self) -> None:
self.previous_theme_style = api.webui_theme_style
self.previous_status_source = api.webui_status_source
self.previous_status_updated_at = api.webui_status_updated_at
+ self.previous_auth_token = api.auth_token
self.previous_logs = list(api.webui_log_lines)
api.bound_celune = None
+ api.auth_token = None
api.webui_log_lines.clear()
api.webui_logs_seeded = True
api.webui_resource_page = 0
@@ -59,34 +64,23 @@ def tearDown(self) -> None:
api.webui_theme_style = self.previous_theme_style
api.webui_status_source = self.previous_status_source
api.webui_status_updated_at = self.previous_status_updated_at
+ api.auth_token = self.previous_auth_token
api.webui_log_lines.clear()
api.webui_log_lines.extend(self.previous_logs)
- def test_root_redirects_to_browser_ui(self) -> None:
- """Verify the fallback root now forwards users to the browser UI."""
- response = api.root()
- self.assertEqual(response.status_code, 307)
- self.assertEqual(response.headers["location"], "/ui")
+ @staticmethod
+ async def _passthrough_response(_request: Request) -> Response:
+ """Return a simple response for middleware pass-through tests."""
+ return Response("ok")
- def test_browser_ui_requests_bypass_api_security_detection(self) -> None:
- """Verify mounted browser UI paths are recognized by the API middleware."""
- ui_request = Request(
- {
- "type": "http",
- "method": "GET",
- "path": "/ui/assets/index.js",
- "headers": [],
- "query_string": b"",
- "client": ("127.0.0.1", 2060),
- "scheme": "http",
- "server": ("127.0.0.1", 2060),
- }
- )
- api_request = Request(
+ @staticmethod
+ def _request(path: str, method: str = "GET") -> Request:
+ """Build one lightweight test request."""
+ return Request(
{
"type": "http",
- "method": "GET",
- "path": "/v1/version",
+ "method": method,
+ "path": path,
"headers": [],
"query_string": b"",
"client": ("127.0.0.1", 2060),
@@ -95,9 +89,47 @@ def test_browser_ui_requests_bypass_api_security_detection(self) -> None:
}
)
+ def test_root_redirects_to_browser_ui(self) -> None:
+ """Verify the fallback root now forwards users to the browser UI."""
+ response = api.root()
+ self.assertEqual(response.status_code, 307)
+ self.assertEqual(response.headers["location"], "/ui")
+
+ def test_browser_ui_requests_bypass_api_security_detection(self) -> None:
+ """Verify mounted browser UI paths are recognized by the API middleware."""
+ ui_request = self._request("/ui/assets/index.js")
+ api_request = self._request("/v1/version")
+
self.assertEqual(api.is_browser_ui_request(ui_request), True)
self.assertEqual(api.is_browser_ui_request(api_request), False)
+ def test_api_security_allows_public_read_only_routes_without_token(self) -> None:
+ """Verify safe read-only routes stay reachable even when API auth is enabled."""
+ api.auth_token = "secret"
+
+ for path in ("/", "/favicon.ico", "/v1", "/v1/version", "/ui"):
+ response = asyncio.run(
+ api.api_security(
+ self._request(path),
+ self._passthrough_response,
+ )
+ )
+ self.assertEqual(response.status_code, 200, path)
+
+ def test_api_security_requires_token_for_generating_routes(self) -> None:
+ """Verify protected API routes still reject unauthenticated requests."""
+ api.auth_token = "secret"
+
+ response = asyncio.run(
+ api.api_security(
+ self._request("/v1/speak", method="POST"),
+ self._passthrough_response,
+ )
+ )
+
+ self.assertEqual(response.status_code, 401)
+ self.assertEqual(response.headers["WWW-Authenticate"], "Bearer")
+
def test_webui_snapshot_uses_bound_celune_state(self) -> None:
"""Verify the browser snapshot mirrors current logs, status, and voice state."""
api.bound_celune = cast(
@@ -184,7 +216,9 @@ def test_webui_wrapped_callbacks_mirror_input_and_voice_lock_state(self) -> None
input_update2,
) = api.webui_snapshot()
self.assertEqual(input_update2["interactive"], True)
- self.assertEqual(input_update2["placeholder"], "Enter text to speak here")
+ self.assertEqual(
+ input_update2["placeholder"], string("webui.input_placeholder")
+ )
self.assertEqual(send_update2["interactive"], True)
self.assertEqual(voice_update2["interactive"], True)
@@ -201,7 +235,7 @@ def test_webui_snapshot_shows_tutorial_placeholder(self) -> None:
),
)
api.webui_input_locked = True
- api.webui_input_placeholder = "Currently in tutorial mode"
+ api.webui_input_placeholder = string("webui.tutorial_placeholder")
api.webui_voice_locked = True
with mock.patch(
@@ -212,10 +246,75 @@ def test_webui_snapshot_shows_tutorial_placeholder(self) -> None:
api.webui_snapshot()
)
self.assertEqual(input_update["interactive"], False)
- self.assertEqual(input_update["placeholder"], "Currently in tutorial mode")
+ self.assertEqual(
+ input_update["placeholder"], string("webui.tutorial_placeholder")
+ )
self.assertEqual(send_update["interactive"], False)
self.assertEqual(voice_update["interactive"], False)
+ def test_webui_snapshot_uses_voice_changer_placeholder_in_vc_mode(self) -> None:
+ """Verify VC mode uses the same voice-changer placeholder as the TUI."""
+ api.bound_celune = cast(
+ Celune,
+ SimpleNamespace(
+ current_voice="balanced",
+ voices=("balanced", "calm"),
+ input_mode="voice_conversion",
+ is_in_tutorial=False,
+ locked=False,
+ cur_state="idle",
+ ),
+ )
+ api.webui_input_locked = False
+
+ with mock.patch(
+ "celune.api.ui_resources.resource_pages",
+ return_value=("VRAM: 10.66/11.94 GB available",),
+ ):
+ _logs, _status, _resources, _voice, _send, input_update = (
+ api.webui_snapshot()
+ )
+
+ self.assertEqual(
+ input_update["placeholder"], string("webui.voice_changer_placeholder")
+ )
+
+ def test_webui_vc_controls_disable_outside_voice_conversion_mode(self) -> None:
+ """Verify VC controls are disabled while Celune is in the normal TTS mode."""
+ api.bound_celune = cast(
+ Celune,
+ SimpleNamespace(
+ input_mode="text_to_speech",
+ ),
+ )
+
+ source_update, pitch_update, mode_update, button_update = (
+ api._webui_vc_controls_update()
+ )
+
+ self.assertEqual(source_update["interactive"], False)
+ self.assertEqual(pitch_update["interactive"], False)
+ self.assertEqual(mode_update["interactive"], False)
+ self.assertEqual(button_update["interactive"], False)
+
+ def test_webui_vc_controls_enable_in_voice_conversion_mode(self) -> None:
+ """Verify VC controls become interactive when the engine is in VC mode."""
+ api.bound_celune = cast(
+ Celune,
+ SimpleNamespace(
+ input_mode="voice_conversion",
+ ),
+ )
+
+ source_update, pitch_update, mode_update, button_update = (
+ api._webui_vc_controls_update()
+ )
+
+ self.assertEqual(source_update["interactive"], True)
+ self.assertEqual(pitch_update["interactive"], True)
+ self.assertEqual(mode_update["interactive"], True)
+ self.assertEqual(button_update["interactive"], True)
+
def test_webui_snapshot_keeps_failed_no_voice_runtime_locked(self) -> None:
"""Verify a failed no-voice runtime stays in an error/locked browser state."""
api.bound_celune = cast(
@@ -229,7 +328,7 @@ def test_webui_snapshot_keeps_failed_no_voice_runtime_locked(self) -> None:
),
)
api.webui_input_locked = False
- api.webui_input_placeholder = "Enter text to speak here"
+ api.webui_input_placeholder = string("webui.input_placeholder")
api.webui_voice_locked = False
with mock.patch(
@@ -448,6 +547,143 @@ def say_stream(content: str, save: bool = True) -> SpeechStreamQueue:
self.assertIsInstance(final_audio, tuple)
self.assertEqual(cast(tuple[int, np.ndarray], final_audio)[0], 48000)
+ def test_webui_convert_audio_returns_browser_audio_after_conversion(self) -> None:
+ """Verify browser audio conversion returns one playable Celune-format payload."""
+ converted_audio = np.ones((10, 2), dtype=np.float32) * 0.5
+ api.bound_celune = cast(
+ Celune,
+ SimpleNamespace(
+ input_mode="voice_conversion",
+ convert_audio=mock.Mock(
+ return_value=SimpleNamespace(
+ audio=converted_audio,
+ sample_rate=24000,
+ label="browser audio input",
+ )
+ ),
+ dev=False,
+ current_voice="balanced",
+ voices=("balanced", "calm"),
+ is_in_tutorial=False,
+ locked=False,
+ cur_state="idle",
+ ),
+ )
+
+ with mock.patch(
+ "celune.api.ui_resources.resource_pages",
+ return_value=("VRAM: 10.66/11.94 GB available",),
+ ):
+ source_value, browser_audio, *_rest = api._webui_convert_audio(
+ (44100, np.zeros((16, 2), dtype=np.float32)),
+ 6.0,
+ "sing",
+ )
+
+ self.assertIsNone(source_value)
+ self.assertIsInstance(browser_audio, tuple)
+ sample_rate, array = cast(tuple[int, np.ndarray], browser_audio)
+ self.assertEqual(sample_rate, 48000)
+ self.assertEqual(array.shape, (20, 2))
+ self.assertEqual(browser_audio[0], 48000)
+ celune_convert_audio = cast(mock.Mock, api.bound_celune.convert_audio)
+ celune_convert_audio.assert_called_once_with(
+ mock.ANY,
+ 44100,
+ label="browser audio input",
+ pitch_shift=6,
+ f0_condition=True,
+ )
+
+ def test_webui_convert_audio_normalizes_integer_browser_audio(self) -> None:
+ """Verify browser-side PCM arrays are normalized before VC conversion."""
+ captured_audio: dict[str, np.ndarray] = {}
+ api.bound_celune = cast(
+ Celune,
+ SimpleNamespace(
+ input_mode="voice_conversion",
+ convert_audio=mock.Mock(
+ side_effect=lambda audio, sample_rate, **_kwargs: (
+ captured_audio.setdefault("audio", np.asarray(audio)),
+ SimpleNamespace(
+ audio=np.zeros((4, 2), dtype=np.float32),
+ sample_rate=sample_rate,
+ label="browser audio input",
+ ),
+ )[1]
+ ),
+ dev=False,
+ current_voice="balanced",
+ voices=("balanced", "calm"),
+ is_in_tutorial=False,
+ locked=False,
+ cur_state="idle",
+ ),
+ )
+
+ source_pcm = np.array(
+ [[32767, -32768], [16384, -16384]],
+ dtype=np.int16,
+ )
+
+ with mock.patch(
+ "celune.api.ui_resources.resource_pages",
+ return_value=("VRAM: 10.66/11.94 GB available",),
+ ):
+ _source_value, browser_audio, *_rest = api._webui_convert_audio(
+ (44100, source_pcm)
+ )
+
+ self.assertIsInstance(browser_audio, tuple)
+ normalized = captured_audio["audio"]
+ self.assertEqual(normalized.dtype, np.float32)
+ self.assertTrue(np.max(np.abs(normalized)) <= 1.0)
+ self.assertAlmostEqual(float(normalized[0, 0]), 32767 / 32768, places=5)
+ self.assertEqual(float(normalized[0, 1]), -1.0)
+
+ def test_webui_convert_audio_rejects_text_to_speech_mode(self) -> None:
+ """Verify browser audio conversion is unavailable outside VC mode."""
+ api.bound_celune = cast(
+ Celune,
+ SimpleNamespace(
+ input_mode="text_to_speech",
+ dev=False,
+ current_voice="balanced",
+ voices=("balanced", "calm"),
+ is_in_tutorial=False,
+ locked=False,
+ cur_state="idle",
+ ),
+ )
+
+ with mock.patch(
+ "celune.api.ui_resources.resource_pages",
+ return_value=("VRAM: 10.66/11.94 GB available",),
+ ):
+ source_value, browser_audio, logs_html, *_rest = api._webui_convert_audio(
+ (44100, np.zeros((8, 2), dtype=np.float32))
+ )
+
+ self.assertIsNotNone(source_value)
+ self.assertEqual(source_value[0], 44100)
+ self.assertIsNone(browser_audio)
+ self.assertIn(string("webui.conversion_only_in_vc_mode"), logs_html)
+
+ def test_build_webui_exposes_tts_and_vc_tabs(self) -> None:
+ """Verify the browser UI separates TTS and VC into distinct tabs."""
+ demo = api._build_webui()
+ config = demo.config
+ tab_labels = [
+ component.get("label") or component.get("props", {}).get("label")
+ for component in config.get("components", [])
+ if component.get("type") == "tabitem"
+ ]
+
+ self.assertEqual(
+ tab_labels,
+ [string("webui.tts_tab_label"), string("webui.vc_tab_label")],
+ )
+
def test_webui_snapshot_probes_runtime_status_and_rotates_resources(self) -> None:
"""Verify footer polling refreshes status and rotates the resource page."""
api.bound_celune = cast(
@@ -476,6 +712,14 @@ def test_webui_snapshot_probes_runtime_status_and_rotates_resources(self) -> Non
self.assertIn("VRAM: first", resources1)
self.assertIn("Friday, June 11, 2026", resources2)
+ def test_webui_shortcuts_html_registers_ctrl_r_recording_toggle(self) -> None:
+ """Verify the WebUI shortcut script exposes the VC recording hotkey."""
+ shortcuts_html = api._webui_shortcuts_html()
+
+ self.assertIn(string("ui.footer_toggle_recording"), shortcuts_html)
+ self.assertIn("#celune-source-audio", shortcuts_html)
+ self.assertIn("keydown", shortcuts_html)
+
def test_webui_runtime_theme_keeps_normal_palette_for_error_status(self) -> None:
"""Verify browser error statuses no longer switch the full UI palette."""
api.set_webui_status("I can't speak right now.", "error")
diff --git a/tests/test_backends_and_extensions.py b/tests/test_backends_and_extensions.py
index df6cf93..f8f9a61 100644
--- a/tests/test_backends_and_extensions.py
+++ b/tests/test_backends_and_extensions.py
@@ -1,16 +1,19 @@
# SPDX-License-Identifier: MIT
"""Tests for backend resolution and extension infrastructure."""
+import re
import sys
import tempfile
import textwrap
import importlib
import threading
+import contextlib
from pathlib import Path
-from typing import Optional
+from typing import Optional, cast
+from types import ModuleType
from types import SimpleNamespace
from unittest import mock, TestCase
-from collections.abc import Iterator
+from collections.abc import Iterator, Generator
import numpy as np
import numpy.typing as npt
@@ -18,16 +21,23 @@
import torch
from celune.utils import discard
-from celune.backends import resolve_backend
+from celune.celune import Celune
+from celune.backends.tts import resolve_backend
+from celune.i18n import string
+from celune.typing.backends import BackendModel
+from celune.backends.vc import resolve_vc_backend
+from celune.backends.vc.passthrough import CelunePassthroughVCBackend
+from celune.backends.vc.seedvc import CeluneSeedVCBackend
from celune.extensions.manager import CeluneExtensionManager
from celune.extensions.base import CeluneContext, CeluneExtension
+from celune.dataclasses.pipeline import VoiceConversionRequest
from celune.exceptions import (
- BackendError,
ExtensionAlreadyRegisteredError,
InvalidExtensionError,
)
from .support import (
FakeBackend,
+ FakeVCBackend,
make_voice_loader,
mock_dotstts_backend,
mock_mini_backend,
@@ -65,7 +75,7 @@ def test_base_backend_materializes_bundle_pt_refs_when_available(self) -> None:
materialize=materialize,
)
- with mock.patch("celune.backends.base.default_loader", return_value=loader):
+ with mock.patch("celune.backends.tts.base.default_loader", return_value=loader):
backend = FakeBackend(log=lambda _msg, _severity="info": None)
backend.validate_refs()
@@ -88,7 +98,7 @@ def test_base_backend_truncates_long_reference_wav_to_ten_seconds(self) -> None:
sf.write(source, np.zeros(12 * 24000, dtype=np.float32), 24000)
with mock.patch(
- "celune.backends.base.temp_data_dir", return_value=canonical_temp
+ "celune.backends.tts.base.temp_data_dir", return_value=canonical_temp
):
truncated = backend.truncate_reference(source)
@@ -109,11 +119,327 @@ def test_resolve_backend_accepts_instance_type_and_rejects_unknown(self) -> None
instance = FakeBackend(log=lambda _msg, _severity="info": None)
self.assertIs(resolve_backend(instance), instance)
self.assertIsInstance(resolve_backend(FakeBackend), FakeBackend)
- with self.assertRaisesRegex(ValueError, "unknown backend"):
+ with self.assertRaisesRegex(
+ ValueError,
+ re.escape(
+ string(
+ "celune.unknown_backend",
+ backend="missing",
+ available="mini, qwen3, dotstts, voxcpm2",
+ )
+ ),
+ ):
resolve_backend("missing")
with self.assertRaisesRegex(TypeError, "backend_name"):
resolve_backend(123) # type: ignore[arg-type]
+ def test_resolve_vc_backend_accepts_instance_type_and_rejects_unknown(self) -> None:
+ """Verify supported VC backend specifications and invalid input failures."""
+ instance = FakeVCBackend(log=lambda _msg, _severity="info": None)
+ self.assertIs(resolve_vc_backend(instance), instance)
+ self.assertIsInstance(resolve_vc_backend(FakeVCBackend), FakeVCBackend)
+ with self.assertRaisesRegex(ValueError, "unknown voice-conversion backend"):
+ resolve_vc_backend("missing")
+ with self.assertRaisesRegex(TypeError, "voice-conversion backend"):
+ resolve_vc_backend(123) # type: ignore[arg-type]
+
+ def test_unload_model_releases_nested_runtime_members(self) -> None:
+ """Verify backend unload clears nested releasable members hidden inside wrapper objects."""
+
+ class NestedRuntime:
+ """Minimal nested runtime object exposing a close hook."""
+
+ def __init__(self) -> None:
+ self.closed = False
+
+ def close(self) -> None:
+ """Record runtime shutdown."""
+ self.closed = True
+
+ class WrapperRuntime:
+ """Minimal wrapper that keeps releasable objects in nested attributes."""
+
+ def __init__(self) -> None:
+ self.inner = NestedRuntime()
+ self.cache = {"child": NestedRuntime()}
+
+ backend = FakeBackend(log=lambda _msg, _severity="info": None)
+ runtime = WrapperRuntime()
+ inner = runtime.inner
+ cached = runtime.cache["child"]
+ backend.model = cast(BackendModel, runtime)
+
+ backend.unload_model()
+
+ self.assertEqual(inner.closed, True)
+ self.assertEqual(cached.closed, True)
+ self.assertIsNone(backend.model)
+
+ def test_passthrough_vc_backend_returns_playable_output(self) -> None:
+ """Verify the passthrough VC backend returns decoded audio unchanged."""
+ backend = CelunePassthroughVCBackend(log=lambda _msg, _severity="info": None)
+ source = np.ones((12, 2), dtype=np.float32)
+
+ output = backend.convert(
+ VoiceConversionRequest(
+ source_audio=source,
+ sample_rate=44100,
+ target_voice="balanced",
+ target_character="Celune",
+ label="fixture audio",
+ )
+ )
+
+ self.assertEqual(output.sample_rate, 44100)
+ self.assertEqual(output.label, "fixture audio")
+ self.assertEqual(output.audio.shape, (12, 2))
+ self.assertEqual(np.array_equal(output.audio, source), True)
+ self.assertIsNot(output.audio, source)
+
+ def test_resolve_vc_backend_accepts_seedvc_backend_name(self) -> None:
+ """Verify the Seed-VC backend resolves through the VC backend registry."""
+ backend = resolve_vc_backend("seed-vc")
+ self.assertIsInstance(backend, CeluneSeedVCBackend)
+ self.assertEqual(backend.name, "seed-vc")
+
+ def test_seedvc_backend_requires_reference_audio(self) -> None:
+ """Verify Seed-VC refuses requests without a target reference WAV."""
+ backend = CeluneSeedVCBackend(log=lambda _msg, _severity="info": None)
+
+ with self.assertRaisesRegex(
+ ValueError, "requires at least one target reference"
+ ):
+ backend.convert(
+ VoiceConversionRequest(
+ source_audio=np.ones((8,), dtype=np.float32),
+ sample_rate=24000,
+ label="fixture",
+ )
+ )
+
+ def test_seedvc_backend_converts_audio_with_cached_wrapper(self) -> None:
+ """Verify Seed-VC wraps converted audio into Celune's VC output contract."""
+ backend = CeluneSeedVCBackend(log=lambda _msg, _severity="info": None)
+ captured: dict[str, object] = {}
+
+ class FakeWrapper:
+ """Minimal Seed-VC wrapper stand-in for one backend test."""
+
+ @staticmethod
+ def convert_voice(**kwargs):
+ """Return a generator-style conversion result for backend tests.
+
+ Args:
+ kwargs: Wrapper arguments forwarded from the backend under test.
+
+ Returns:
+ Generator[None, None, npt.NDArray[np.float32]]: One generator whose return value carries the
+ converted waveform.
+ """
+ captured.update(kwargs)
+ assert Path(str(kwargs["source"])).exists()
+ assert Path(str(kwargs["target"])).exists()
+
+ def result_generator() -> Generator[
+ None, None, npt.NDArray[np.float32]
+ ]:
+ yield from ()
+ return np.array([0.25, -0.25], dtype=np.float32)
+
+ return result_generator()
+
+ backend._wrapper = FakeWrapper()
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ target = Path(temp_dir) / "target.wav"
+ sf.write(target, np.zeros((16,), dtype=np.float32), 24000)
+
+ output = backend.convert(
+ VoiceConversionRequest(
+ source_audio=np.ones((12, 2), dtype=np.float32),
+ sample_rate=24000,
+ target_voice="balanced",
+ target_character="Celune",
+ target_references=(target,),
+ label="fixture audio",
+ )
+ )
+
+ self.assertEqual(output.sample_rate, 22050)
+ self.assertEqual(output.label, "fixture audio")
+ self.assertEqual(output.audio.dtype, np.float32)
+ self.assertEqual(output.audio.tolist(), [0.25, -0.25])
+ self.assertEqual(captured["stream_output"], False)
+ self.assertEqual(captured["f0_condition"], False)
+ self.assertEqual(captured["pitch_shift"], 0)
+
+ def test_seedvc_backend_uses_configured_pitch_shift_for_wrapper_requests(
+ self,
+ ) -> None:
+ """Verify Seed-VC forwards its configured pitch shift into wrapper requests."""
+ backend = CeluneSeedVCBackend(
+ log=lambda _msg, _severity="info": None,
+ pitch_shift=-6,
+ )
+ captured: dict[str, object] = {}
+
+ class FakeWrapper:
+ """Minimal Seed-VC wrapper stand-in for request override coverage."""
+
+ @staticmethod
+ def convert_voice(**kwargs):
+ """Return a generator-style conversion result for backend tests.
+
+ Args:
+ kwargs: Wrapper arguments forwarded from the backend under test.
+
+ Returns:
+ Generator[None, None, npt.NDArray[np.float32]]: One generator whose return value carries the
+ converted waveform.
+ """
+ captured.update(kwargs)
+
+ def result_generator() -> Generator[
+ None, None, npt.NDArray[np.float32]
+ ]:
+ yield from ()
+ return np.array([0.1, -0.1], dtype=np.float32)
+
+ return result_generator()
+
+ backend._wrapper = FakeWrapper()
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ target = Path(temp_dir) / "target.wav"
+ sf.write(target, np.zeros((16,), dtype=np.float32), 24000)
+
+ backend.convert(
+ VoiceConversionRequest(
+ source_audio=np.ones((12, 2), dtype=np.float32),
+ sample_rate=24000,
+ target_references=(target,),
+ pitch_shift=9,
+ )
+ )
+
+ self.assertEqual(captured["pitch_shift"], 9)
+
+ def test_seedvc_backend_prefers_request_f0_condition_over_backend_default(
+ self,
+ ) -> None:
+ """Verify one conversion request can override talk vs sing mode."""
+ backend = CeluneSeedVCBackend(
+ log=lambda _msg, _severity="info": None,
+ f0_condition=False,
+ )
+ captured: dict[str, object] = {}
+
+ class FakeWrapper:
+ """Minimal Seed-VC wrapper stand-in for f0 override coverage."""
+
+ @staticmethod
+ def convert_voice(**kwargs):
+ """Return a generator-style conversion result for backend tests.
+
+ Args:
+ kwargs: Wrapper arguments forwarded from the backend under test.
+
+ Returns:
+ Generator[None, None, npt.NDArray[np.float32]]: One generator whose return value carries the
+ converted waveform.
+ """
+ captured.update(kwargs)
+
+ def result_generator() -> Generator[
+ None, None, npt.NDArray[np.float32]
+ ]:
+ yield from ()
+ return np.array([0.1, -0.1], dtype=np.float32)
+
+ return result_generator()
+
+ backend._wrapper = FakeWrapper()
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ target = Path(temp_dir) / "target.wav"
+ sf.write(target, np.zeros((16,), dtype=np.float32), 24000)
+
+ output = backend.convert(
+ VoiceConversionRequest(
+ source_audio=np.ones((12, 2), dtype=np.float32),
+ sample_rate=24000,
+ target_references=(target,),
+ f0_condition=True,
+ )
+ )
+
+ self.assertEqual(captured["f0_condition"], True)
+ self.assertEqual(output.sample_rate, 44100)
+
+ def test_seedvc_backend_redirects_package_checkpoint_downloads_into_hf_cache(
+ self,
+ ) -> None:
+ """Verify Celune redirects Seed-VC downloads into the shared Hugging Face cache."""
+ backend = CeluneSeedVCBackend(log=lambda _msg, _severity="info": None)
+ captured: list[tuple[str, str, str]] = []
+
+ fake_hf_utils = ModuleType("seed_vc.hf_utils")
+ fake_wrapper = ModuleType("seed_vc.seed_vc_wrapper")
+
+ def fake_hf_hub_download(
+ repo_id: str,
+ filename: str,
+ cache_dir: str,
+ ) -> str:
+ captured.append((repo_id, filename, cache_dir))
+ return str(Path(cache_dir) / filename)
+
+ setattr(fake_hf_utils, "hf_hub_download", fake_hf_hub_download)
+ setattr(
+ fake_hf_utils, "load_custom_model_from_hf", lambda *args, **kwargs: None
+ )
+ setattr(fake_wrapper, "load_custom_model_from_hf", lambda *args, **kwargs: None)
+ setattr(fake_wrapper, "SeedVCWrapper", type("FakeSeedVCWrapper", (), {}))
+
+ def import_module(name: str) -> ModuleType:
+ if name == "seed_vc.hf_utils":
+ return fake_hf_utils
+ if name == "seed_vc.seed_vc_wrapper":
+ return fake_wrapper
+ return importlib.import_module(name)
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ expected_cache_dir = Path(temp_dir) / "huggingface" / "hub"
+
+ with (
+ mock.patch(
+ "celune.backends.vc.seedvc.huggingface_hub_cache_dir",
+ return_value=expected_cache_dir,
+ ),
+ mock.patch(
+ "celune.backends.vc.seedvc.importlib.import_module",
+ side_effect=import_module,
+ ),
+ ):
+ backend._load_wrapper_type()
+ resolved = getattr(fake_wrapper, "load_custom_model_from_hf")(
+ "funasr/campplus",
+ "campplus_cn_common.bin",
+ None,
+ )
+
+ self.assertEqual(Path(resolved), expected_cache_dir / "campplus_cn_common.bin")
+ self.assertEqual(
+ captured,
+ [
+ (
+ "funasr/campplus",
+ "campplus_cn_common.bin",
+ str(expected_cache_dir),
+ )
+ ],
+ )
+
def test_resolve_backend_accepts_mini_backend_name(self) -> None:
"""Verify the Pocket TTS backend resolves through the backend registry."""
@@ -124,7 +450,7 @@ class StubTTSModel:
sys.modules,
{"pocket_tts": SimpleNamespace(TTSModel=StubTTSModel)},
):
- mini = importlib.import_module("celune.backends.mini")
+ mini = importlib.import_module("celune.backends.tts.mini")
mini_cls = mini.Mini
with mock.patch.object(mini_cls, "_validate_refs"):
@@ -176,7 +502,7 @@ def generate_streaming(
voxcpm2_cls, "_truncate_reference", side_effect=lambda path: path
),
mock.patch(
- "celune.backends.voxcpm2.default_loader", return_value=loader
+ "celune.backends.tts.voxcpm2.default_loader", return_value=loader
),
):
backend = voxcpm2_cls(log=lambda _msg, _severity="info": None)
@@ -198,10 +524,10 @@ def test_voxcpm2_requires_reference_text_for_valid_voice_identifiers(self) -> No
with mock_voxcpm_backend() as voxcpm2_cls:
loader = make_voice_loader("calm", {})
with mock.patch(
- "celune.backends.voxcpm2.default_loader", return_value=loader
+ "celune.backends.tts.voxcpm2.default_loader", return_value=loader
):
with self.assertRaisesRegex(
- BackendError, "requires a compatible CEVOICE/CECHAR package"
+ FileNotFoundError, "requires a compatible CEVOICE/CECHAR package"
):
voxcpm2_cls(log=lambda _msg, _severity="info": None)
@@ -234,7 +560,7 @@ def generate_streaming(
)
with (
mock.patch(
- "celune.backends.voxcpm2.default_loader", return_value=loader
+ "celune.backends.tts.voxcpm2.default_loader", return_value=loader
),
mock.patch.object(
voxcpm2_cls, "_truncate_reference", return_value=Path("trimmed.wav")
@@ -258,10 +584,10 @@ def test_voxcpm2_requires_a_compatible_voice_pack(self) -> None:
with (
mock_voxcpm_backend() as voxcpm2_cls,
- mock.patch("celune.backends.voxcpm2.default_loader", return_value=None),
+ mock.patch("celune.backends.tts.voxcpm2.default_loader", return_value=None),
):
with self.assertRaisesRegex(
- BackendError, "requires a compatible CEVOICE/CECHAR package"
+ FileNotFoundError, "requires a compatible CEVOICE/CECHAR package"
):
voxcpm2_cls(log=lambda _msg, _severity="info": None)
@@ -270,9 +596,11 @@ def test_mini_requires_reference_text_for_valid_voice_identifiers(self) -> None:
with mock_mini_backend() as mini_cls:
loader = make_voice_loader("calm", {})
- with mock.patch("celune.backends.mini.default_loader", return_value=loader):
+ with mock.patch(
+ "celune.backends.tts.mini.default_loader", return_value=loader
+ ):
with self.assertRaisesRegex(
- BackendError, "requires a compatible CEVOICE/CECHAR package"
+ FileNotFoundError, "requires a compatible CEVOICE/CECHAR package"
):
mini_cls(log=lambda _msg, _severity="info": None)
@@ -317,7 +645,9 @@ def generate_audio_stream(
yield torch.zeros((1,), dtype=torch.float32)
with (
- mock.patch("celune.backends.mini.default_loader", return_value=loader),
+ mock.patch(
+ "celune.backends.tts.mini.default_loader", return_value=loader
+ ),
mock.patch.object(
mini_cls, "_truncate_reference", return_value=Path("trimmed.wav")
),
@@ -336,7 +666,9 @@ def test_mini_does_not_apply_reference_wav_truncation_to_reference_text(
with mock_mini_backend() as mini_cls:
loader = make_voice_loader("calm", {"reference_text": "Pack reference."})
with (
- mock.patch("celune.backends.mini.default_loader", return_value=loader),
+ mock.patch(
+ "celune.backends.tts.mini.default_loader", return_value=loader
+ ),
mock.patch.object(
mini_cls,
"_truncate_reference",
@@ -351,10 +683,10 @@ def test_mini_requires_a_compatible_voice_pack(self) -> None:
with (
mock_mini_backend() as mini_cls,
- mock.patch("celune.backends.mini.default_loader", return_value=None),
+ mock.patch("celune.backends.tts.mini.default_loader", return_value=None),
):
with self.assertRaisesRegex(
- BackendError, "requires a compatible CEVOICE/CECHAR package"
+ FileNotFoundError, "requires a compatible CEVOICE/CECHAR package"
):
mini_cls(log=lambda _msg, _severity="info": None)
@@ -390,7 +722,9 @@ def generate_voice_clone_streaming(
mock.patch.object(
qwen3_cls, "_truncate_reference", side_effect=lambda path: path
),
- mock.patch("celune.backends.qwen3.default_loader", return_value=loader),
+ mock.patch(
+ "celune.backends.tts.qwen3.default_loader", return_value=loader
+ ),
):
backend = qwen3_cls(log=lambda _msg, _severity="info": None)
model = FakeModel()
@@ -427,7 +761,9 @@ def generate_voice_clone_streaming(
loader = make_voice_loader("calm", {"reference_text": "Pack reference."})
with (
mock.patch.object(qwen3_cls, "_validate_refs"),
- mock.patch("celune.backends.qwen3.default_loader", return_value=loader),
+ mock.patch(
+ "celune.backends.tts.qwen3.default_loader", return_value=loader
+ ),
mock.patch.object(
qwen3_cls, "_truncate_reference", return_value=Path("trimmed.wav")
),
@@ -472,7 +808,7 @@ def generate_stream(self, *args, **kwargs) -> Iterator[torch.Tensor]:
dotstts_cls, "_truncate_reference", side_effect=lambda path: path
),
mock.patch(
- "celune.backends.dotstts.default_loader", return_value=loader
+ "celune.backends.tts.dotstts.default_loader", return_value=loader
),
):
backend = dotstts_cls(log=lambda _msg, _severity="info": None)
@@ -511,7 +847,7 @@ def generate_stream(self, *args, **kwargs) -> Iterator[torch.Tensor]:
with (
mock.patch.object(dotstts_cls, "_validate_refs"),
mock.patch(
- "celune.backends.dotstts.default_loader", return_value=loader
+ "celune.backends.tts.dotstts.default_loader", return_value=loader
),
mock.patch.object(
dotstts_cls, "_truncate_reference", return_value=Path("trimmed.wav")
@@ -557,7 +893,7 @@ def generate_stream(self, *args, **kwargs) -> Iterator[torch.Tensor]:
dotstts_cls, "_truncate_reference", side_effect=lambda path: path
),
mock.patch(
- "celune.backends.dotstts.default_loader", return_value=loader
+ "celune.backends.tts.dotstts.default_loader", return_value=loader
),
):
backend = dotstts_cls(log=lambda _msg, _severity="info": None)
@@ -573,10 +909,10 @@ def test_dotstts_requires_reference_text_for_valid_voice_identifiers(self) -> No
with mock_dotstts_backend() as dotstts_cls:
loader = make_voice_loader("calm", {})
with mock.patch(
- "celune.backends.dotstts.default_loader", return_value=loader
+ "celune.backends.tts.dotstts.default_loader", return_value=loader
):
with self.assertRaisesRegex(
- BackendError, "requires a compatible CEVOICE/CECHAR package"
+ FileNotFoundError, "requires a compatible CEVOICE/CECHAR package"
):
dotstts_cls(log=lambda _msg, _severity="info": None)
@@ -585,10 +921,10 @@ def test_dotstts_requires_a_compatible_voice_pack(self) -> None:
with (
mock_dotstts_backend() as dotstts_cls,
- mock.patch("celune.backends.dotstts.default_loader", return_value=None),
+ mock.patch("celune.backends.tts.dotstts.default_loader", return_value=None),
):
with self.assertRaisesRegex(
- BackendError, "requires a compatible CEVOICE/CECHAR package"
+ FileNotFoundError, "requires a compatible CEVOICE/CECHAR package"
):
dotstts_cls(log=lambda _msg, _severity="info": None)
@@ -648,7 +984,7 @@ def generate_stream(self, *args, **kwargs) -> FakeStream:
dotstts_cls, "_truncate_reference", side_effect=lambda path: path
),
mock.patch(
- "celune.backends.dotstts.default_loader", return_value=loader
+ "celune.backends.tts.dotstts.default_loader", return_value=loader
),
):
backend = dotstts_cls(log=lambda _msg, _severity="info": None)
@@ -667,7 +1003,7 @@ def test_dotstts_suppresses_loguru_runtime_noise(self) -> None:
with mock_dotstts_backend() as dotstts_cls:
fake_loguru = mock.Mock()
- with mock.patch("celune.backends.dotstts.loguru.logger", fake_loguru):
+ with mock.patch("celune.backends.tts.dotstts.loguru.logger", fake_loguru):
with dotstts_cls.suppress_backend_output():
pass
@@ -680,10 +1016,10 @@ def test_qwen3_requires_reference_text_for_valid_voice_identifiers(self) -> None
with mock_qwen3_backend() as qwen3_cls:
loader = make_voice_loader("calm", {})
with mock.patch(
- "celune.backends.qwen3.default_loader", return_value=loader
+ "celune.backends.tts.qwen3.default_loader", return_value=loader
):
with self.assertRaisesRegex(
- BackendError, "requires a compatible CEVOICE/CECHAR package"
+ FileNotFoundError, "requires a compatible CEVOICE/CECHAR package"
):
qwen3_cls(log=lambda _msg, _severity="info": None)
@@ -692,10 +1028,10 @@ def test_qwen3_requires_a_compatible_voice_pack(self) -> None:
with (
mock_qwen3_backend() as qwen3_cls,
- mock.patch("celune.backends.qwen3.default_loader", return_value=None),
+ mock.patch("celune.backends.tts.qwen3.default_loader", return_value=None),
):
with self.assertRaisesRegex(
- BackendError, "requires a compatible CEVOICE/CECHAR package"
+ FileNotFoundError, "requires a compatible CEVOICE/CECHAR package"
):
qwen3_cls(log=lambda _msg, _severity="info": None)
@@ -712,10 +1048,10 @@ def test_qwen3_requires_at_least_one_valid_voice_identifier(self) -> None:
with (
mock_qwen3_backend() as qwen3_cls,
- mock.patch("celune.backends.qwen3.default_loader", return_value=loader),
+ mock.patch("celune.backends.tts.qwen3.default_loader", return_value=loader),
):
with self.assertRaisesRegex(
- BackendError, "requires a compatible CEVOICE/CECHAR package"
+ FileNotFoundError, "requires a compatible CEVOICE/CECHAR package"
):
qwen3_cls(log=lambda _msg, _severity="info": None)
@@ -774,7 +1110,9 @@ def generate_voice_clone_streaming(self, *args, **kwargs) -> FakeStream:
mock.patch.object(
qwen3_cls, "_truncate_reference", side_effect=lambda path: path
),
- mock.patch("celune.backends.qwen3.default_loader", return_value=loader),
+ mock.patch(
+ "celune.backends.tts.qwen3.default_loader", return_value=loader
+ ),
):
backend = qwen3_cls(log=lambda _msg, _severity="info": None)
model = FakeModel()
@@ -823,7 +1161,9 @@ def generate_voice_clone_streaming(
mock.patch.object(
qwen3_cls, "_truncate_reference", side_effect=lambda path: path
),
- mock.patch("celune.backends.qwen3.default_loader", return_value=loader),
+ mock.patch(
+ "celune.backends.tts.qwen3.default_loader", return_value=loader
+ ),
):
backend = qwen3_cls(log=lambda _msg, _severity="info": None)
chunk = next(
@@ -864,7 +1204,7 @@ def generate_streaming(
voxcpm2_cls, "_truncate_reference", side_effect=lambda path: path
),
mock.patch(
- "celune.backends.voxcpm2.default_loader", return_value=loader
+ "celune.backends.tts.voxcpm2.default_loader", return_value=loader
),
):
backend = voxcpm2_cls(log=lambda _msg, _severity="info": None)
@@ -884,6 +1224,17 @@ class ExtensionTests(TestCase):
"""Tests for extension context and manager behavior."""
def setUp(self) -> None:
+ self.backend_override = mock.Mock(
+ side_effect=lambda backend_name: contextlib.nullcontext(
+ cast(Celune, SimpleNamespace())
+ )
+ )
+
+ self.cevoice_override = mock.Mock(
+ side_effect=lambda bundle: contextlib.nullcontext(
+ cast(Celune, SimpleNamespace())
+ )
+ )
self.logs: list[tuple[str, str]] = []
self.dev_logs: list[tuple[str, str]] = []
self.invocations: list[tuple[str, tuple[str, ...]]] = []
@@ -900,6 +1251,8 @@ def setUp(self) -> None:
set_voice=lambda name: True,
get_state=lambda: "idle",
wait_until_ready=lambda timeout=30.0: True,
+ backend_override=self.backend_override,
+ cevoice_override=self.cevoice_override,
)
def test_context_and_extension_helpers_delegate_calls(self) -> None:
@@ -921,6 +1274,12 @@ def test_context_and_extension_helpers_delegate_calls(self) -> None:
self.assertEqual(extension.play("quiet.wav", keep=True, volume=0.25), True)
self.assertEqual(self.play_calls[-1], ("quiet.wav", True, 0.25))
self.assertEqual(extension.set_voice("bold"), True)
+ with extension.with_backend("mini"):
+ pass
+ with extension.with_cevoice("nova"):
+ pass
+ self.backend_override.assert_called_once_with("mini")
+ self.cevoice_override.assert_called_once_with("nova")
def test_manager_registers_invokes_and_autoloads_extensions(self) -> None:
"""Verify registration, duplicate handling, and directory autoloading.
diff --git a/tests/test_backends_mini.py b/tests/test_backends_mini.py
index 3509652..ee0d86a 100644
--- a/tests/test_backends_mini.py
+++ b/tests/test_backends_mini.py
@@ -9,7 +9,7 @@
from pocket_tts import TTSModel
-from celune.backends.mini import Mini
+from celune.backends.tts.mini import Mini
class MiniBackendTests(TestCase):
diff --git a/tests/test_celune_core.py b/tests/test_celune_core.py
index 5187f0f..f7ba7dc 100644
--- a/tests/test_celune_core.py
+++ b/tests/test_celune_core.py
@@ -2,23 +2,34 @@
"""Tests for Celune core behavior without real models or GPU work."""
import threading
+import contextlib
+import weakref
+import tempfile
from typing import cast
from pathlib import Path
from types import SimpleNamespace
from unittest import mock, TestCase
+import numpy as np
from transformers.modeling_utils import PreTrainedModel
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
from celune.celune import Celune
+from celune import cevoice
from celune.config import Config
-from celune.backends.qwen3 import Qwen3
+from celune.backends.tts.qwen3 import Qwen3
from celune.constants import JSONSerializable
-from celune.pipeline import play_signal, release_pipeline
+from celune.pipeline import (
+ convert_audio_input,
+ handle_audio_input,
+ play_signal,
+ release_pipeline,
+)
from celune.vram import QWEN3_0_6B_MODEL
from celune.persona.impl import persona_quantization
from celune.exceptions import BackendError, WarmupError
-from .support import FakeBackend, FakeGlow
+from celune.utils import discard
+from .support import FakeBackend, FakeGlow, FakeVCBackend
class CeluneCoreTests(TestCase):
@@ -41,6 +52,25 @@ def _make_celune(self, config: dict) -> Celune:
self.addCleanup(self._close_celune, celune)
return celune
+ @staticmethod
+ def _immediate_thread(*args, **kwargs):
+ """Return a thread stub whose ``start()`` runs the target immediately."""
+ target = kwargs.get("target")
+ if target is None and args:
+ target = args[0]
+ target_args = kwargs.get("args", ())
+
+ class _ImmediateThread:
+ """Immediate thread harness used by hot-reload tests."""
+
+ @staticmethod
+ def start() -> None:
+ """Run the target synchronously."""
+ if target is not None:
+ target(*target_args)
+
+ return _ImmediateThread()
+
def test_constructor_validates_backend_and_chunk_size(self) -> None:
"""Verify constructor validation and derived chunk size behavior.
@@ -81,6 +111,85 @@ def test_constructor_validates_backend_and_chunk_size(self) -> None:
target_chunk_length=0.65,
)
+ def test_constructor_accepts_backend_alias_for_tts_runtime(self) -> None:
+ """Verify ``backend=`` can configure the TTS runtime directly."""
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ ):
+ celune = Celune(config={}, backend=FakeBackend)
+ self.addCleanup(self._close_celune, celune)
+
+ self.assertEqual(celune.backend.name, "fake")
+ self.assertEqual(celune.tts_backend, "fake")
+
+ def test_constructor_accepts_backend_alias_for_vc_runtime(self) -> None:
+ """Verify ``backend=`` can configure the VC runtime in VC mode."""
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ mock.patch(
+ "celune.celune.resolve_backend",
+ return_value=FakeBackend(log=lambda _msg, _severity="info": None),
+ ),
+ ):
+ celune = Celune(config={"mode": "voice_conversion"}, backend=FakeVCBackend)
+ self.addCleanup(self._close_celune, celune)
+
+ self.assertEqual(celune.input_mode, "voice_conversion")
+ self.assertIsNotNone(celune.vc_backend)
+ assert celune.vc_backend is not None
+ self.assertEqual(celune.vc_backend.name, "fake-vc")
+
+ def test_constructor_accepts_backend_alias_string_for_vc_runtime(self) -> None:
+ """Verify string backend aliases resolve to VC backends when selected."""
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ mock.patch(
+ "celune.celune.resolve_backend",
+ return_value=FakeBackend(log=lambda _msg, _severity="info": None),
+ ),
+ ):
+ celune = Celune(config={"mode": "voice_conversion"}, backend="passthrough")
+ self.addCleanup(self._close_celune, celune)
+
+ self.assertIsNotNone(celune.vc_backend)
+ assert celune.vc_backend is not None
+ self.assertEqual(celune.vc_backend.name, "passthrough")
+
+ def test_constructor_rejects_duplicate_backend_alias_for_tts(self) -> None:
+ """Verify ``backend=`` cannot be combined with ``tts_backend=``."""
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ self.assertRaisesRegex(
+ BackendError, "cannot specify both 'backend' and 'tts_backend'"
+ ),
+ ):
+ Celune(config={}, backend=FakeBackend, tts_backend=FakeBackend)
+
+ def test_constructor_rejects_duplicate_backend_alias_for_vc(self) -> None:
+ """Verify ``backend=`` cannot be combined with ``vc_backend=``."""
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ self.assertRaisesRegex(
+ BackendError, "cannot specify both 'backend' and 'vc_backend'"
+ ),
+ ):
+ Celune(
+ config={"mode": "voice_conversion"},
+ tts_backend=FakeBackend,
+ backend=FakeVCBackend,
+ vc_backend=FakeVCBackend,
+ )
+
def test_voice_loading_uses_backend_and_bundle_defaults(self) -> None:
"""Verify backend voices and bundle metadata determine defaults.
@@ -93,6 +202,7 @@ def test_voice_loading_uses_backend_and_bundle_defaults(self) -> None:
self.assertEqual(celune.current_voice, "balanced")
fake_bundle = mock.Mock()
+ fake_bundle.path = Path("fixture.cevoice")
fake_bundle.voice_order = ("bold", "balanced")
fake_bundle.metadata = {
"name": "Pack Name",
@@ -111,7 +221,13 @@ def test_voice_loading_uses_backend_and_bundle_defaults(self) -> None:
}
fake_loader = mock.Mock(bundle=fake_bundle)
celune.backend.uses_voice_bundles = True
- with mock.patch("celune.celune.default_loader", return_value=fake_loader):
+ with (
+ mock.patch("celune.celune.default_loader", return_value=fake_loader),
+ mock.patch(
+ "celune.celune.bundle_matches_default_pack_checksum",
+ return_value=False,
+ ),
+ ):
self.assertEqual(celune.load_voice_bundle(Path("fixture.cevoice")), True)
self.assertEqual(celune.current_voice, "bold")
self.assertEqual(celune.current_character, "Fixture")
@@ -122,6 +238,114 @@ def test_voice_loading_uses_backend_and_bundle_defaults(self) -> None:
"Measured and calm.",
)
+ def test_cleanup_residual_temp_data_skips_core_cevoice_files(self) -> None:
+ """Verify startup temp cleanup removes only disposable Celune temp artifacts."""
+ celune = self._make_celune({})
+ celune.log_callback = mock.Mock()
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_root = Path(temp_dir)
+ extracted_dir = temp_root / "celune-cevoice-fixture"
+ extracted_dir.mkdir()
+ (extracted_dir / "balanced.wav").write_bytes(b"wav")
+ rag_prompt = temp_root / "rag_prompt.txt"
+ rag_prompt.write_text("prompt", encoding="utf-8")
+ temporary_audio = temp_root / "temporary_audio.wav"
+ temporary_audio.write_bytes(b"RIFFdemoWAVE")
+ bundle_file = temp_root / "default.cevoice"
+ bundle_file.write_bytes(b"core")
+ memory_note = temp_root / "keep.txt"
+ memory_note.write_text("keep", encoding="utf-8")
+
+ celune._cleanup_residual_temp_data(temp_root)
+
+ self.assertFalse(extracted_dir.exists())
+ self.assertFalse(rag_prompt.exists())
+ self.assertFalse(temporary_audio.exists())
+ self.assertTrue(bundle_file.exists())
+ self.assertTrue(memory_note.exists())
+
+ celune.log_callback.assert_any_call(
+ "Celune found 3 residual temporary items.",
+ "warning",
+ )
+ celune.log_callback.assert_any_call("Deleting...", "warning")
+
+ def test_cleanup_residual_temp_data_preserves_protected_temp_paths(self) -> None:
+ """Verify live protected temp paths survive cleanup even when names match disposable prefixes."""
+ celune = self._make_celune({})
+ celune.log_callback = mock.Mock()
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_root = Path(temp_dir)
+ protected_dir = temp_root / "celune-cevoice-live"
+ protected_dir.mkdir()
+ (protected_dir / "balanced.wav").write_bytes(b"wav")
+ stale_dir = temp_root / "celune-cevoice-stale"
+ stale_dir.mkdir()
+ stale_file = temp_root / "temporary_audio.wav"
+ stale_file.write_bytes(b"RIFFdemoWAVE")
+
+ cevoice.register_protected_temp_path(protected_dir)
+ try:
+ celune._cleanup_residual_temp_data(temp_root)
+ finally:
+ cevoice.unregister_protected_temp_path(protected_dir)
+
+ self.assertTrue(protected_dir.exists())
+ self.assertFalse(stale_dir.exists())
+ self.assertFalse(stale_file.exists())
+
+ celune.log_callback.assert_any_call(
+ "Celune found 2 residual temporary items.",
+ "warning",
+ )
+
+ def test_load_voice_bundle_marks_default_pack_from_checksum(self) -> None:
+ """Verify default-pack detection follows the CEVOICE checksum, not the character name."""
+ celune = self._make_celune({})
+ fake_bundle = mock.Mock()
+ fake_bundle.path = Path("renamed-default.cevoice")
+ fake_bundle.voice_order = ("balanced", "bold")
+ fake_bundle.metadata = {"name": "Pack Name", "default_voice": "balanced"}
+ fake_loader = mock.Mock(bundle=fake_bundle)
+ celune.backend.uses_voice_bundles = True
+
+ with (
+ mock.patch("celune.celune.default_loader", return_value=fake_loader),
+ mock.patch(
+ "celune.celune.bundle_matches_default_pack_checksum",
+ return_value=True,
+ ),
+ ):
+ self.assertEqual(celune.load_voice_bundle(Path("fixture.cevoice")), True)
+
+ self.assertEqual(celune.voice_bundle_is_default, True)
+
+ def test_load_voice_bundle_rejects_named_celune_without_default_checksum(
+ self,
+ ) -> None:
+ """Verify non-default packs named Celune do not inherit default-pack behavior."""
+ celune = self._make_celune({})
+ fake_bundle = mock.Mock()
+ fake_bundle.path = Path("custom-celune.cevoice")
+ fake_bundle.voice_order = ("balanced", "bold")
+ fake_bundle.metadata = {"name": "Celune", "default_voice": "balanced"}
+ fake_loader = mock.Mock(bundle=fake_bundle)
+ celune.backend.uses_voice_bundles = True
+
+ with (
+ mock.patch("celune.celune.default_loader", return_value=fake_loader),
+ mock.patch(
+ "celune.celune.bundle_matches_default_pack_checksum",
+ return_value=False,
+ ),
+ ):
+ self.assertEqual(celune.load_voice_bundle(Path("fixture.cevoice")), True)
+
+ self.assertEqual(celune.current_character, "Celune")
+ self.assertEqual(celune.voice_bundle_is_default, False)
+
def test_persona_connection_uses_in_process_runtime(self) -> None:
"""Verify Celune connects to Persona through the local in-process runtime.
@@ -200,6 +424,33 @@ def test_load_preloads_persona_runtime_when_available(self) -> None:
"4bit",
)
+ def test_load_cleans_temp_before_loading_voice_bundle(self) -> None:
+ """Verify startup temp cleanup runs before any CEVOICE loader work begins."""
+ celune = self._make_celune({})
+ celune.backend.uses_voice_bundles = True
+ call_order: list[str] = []
+
+ def cleanup(_temp_dir: Path) -> None:
+ call_order.append("cleanup")
+
+ def load_voices() -> bool:
+ call_order.append("voices")
+ return False
+
+ celune._try_play_signal = mock.Mock(return_value=False)
+ celune.error_callback = mock.Mock()
+
+ with (
+ mock.patch.object(
+ celune, "_cleanup_residual_temp_data", side_effect=cleanup
+ ),
+ mock.patch.object(celune, "load_available_voices", side_effect=load_voices),
+ mock.patch("celune.celune.log_runtime_banner"),
+ ):
+ self.assertEqual(celune.load(), False)
+
+ self.assertEqual(call_order, ["cleanup", "voices"])
+
def test_load_disables_persona_when_preload_fails(self) -> None:
"""Verify Persona preload failures fall back to speech-only mode."""
celune = self._make_celune({})
@@ -223,6 +474,45 @@ def test_load_disables_persona_when_preload_fails(self) -> None:
persona_client.close.assert_called_once_with()
self.assertIsNone(celune.vision)
+ def test_load_voice_conversion_mode_skips_tts_model_load_and_warmup(self) -> None:
+ """Verify VC mode does not boot the TTS runtime during startup."""
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ ):
+ celune = Celune(
+ config={"mode": "voice_conversion"},
+ tts_backend=FakeBackend,
+ vc_backend=FakeVCBackend,
+ )
+ self.addCleanup(self._close_celune, celune)
+
+ celune.setup_extensions = mock.Mock()
+ celune._warmup = mock.Mock(return_value=True)
+ celune._start_configured_api = mock.Mock()
+ celune.backend.preload_models = mock.Mock()
+ celune.backend.load_default_model = mock.Mock(return_value={"model": "unused"})
+ assert celune.vc_backend is not None
+ celune.vc_backend.preload_models = mock.Mock()
+
+ with (
+ mock.patch("celune.celune.threading.Thread") as thread_cls,
+ mock.patch("celune.celune.validate_runtime", return_value=True),
+ mock.patch("celune.celune.play_signal", return_value=False),
+ ):
+ thread_cls.return_value.start = mock.Mock()
+ self.assertEqual(celune.load(), True)
+
+ celune.backend.preload_models.assert_not_called()
+ celune.backend.load_default_model.assert_not_called()
+ celune._warmup.assert_not_called()
+ celune.vc_backend.preload_models.assert_called_once_with()
+ self.assertIsNone(celune.model)
+ self.assertEqual(celune.model_name, "")
+ self.assertEqual(celune._generation_thread, None)
+ self.assertEqual(thread_cls.call_count, 1)
+
def test_change_voice_returns_runtime_state_to_idle(self) -> None:
"""Verify successful voice reload leaves Celune in the idle state."""
celune = self._make_celune({})
@@ -247,6 +537,35 @@ def test_change_voice_returns_runtime_state_to_idle(self) -> None:
self.assertEqual(statuses[-1], ("Idle", "info"))
celune.voice_changed_callback.assert_called_once_with("bold")
+ def test_change_voice_in_voice_conversion_mode_skips_tts_reload(self) -> None:
+ """Verify VC mode updates the target voice without loading TTS models."""
+ celune = self._make_celune({})
+ celune.input_mode = "voice_conversion"
+ celune.current_voice = "balanced"
+ celune.voices = ("balanced", "bold")
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.backend.model_id_for_voice = mock.Mock(return_value="shared-model")
+ celune.backend.load_model = mock.Mock(return_value={"model": "unused"})
+ celune._warmup = mock.Mock(return_value=True)
+ statuses: list[tuple[str, str]] = []
+ celune.status_callback = lambda msg, severity="info": statuses.append(
+ (msg, severity)
+ )
+ celune.voice_changed_callback = mock.Mock()
+
+ with mock.patch("celune.celune.play_signal", return_value=False):
+ celune.change_voice("bold")
+
+ self.assertEqual(celune.current_voice, "bold")
+ self.assertEqual(celune.loaded, True)
+ self.assertEqual(celune.cur_state, "idle")
+ self.assertEqual(statuses[-1], ("Idle", "info"))
+ celune.backend.model_id_for_voice.assert_not_called()
+ celune.backend.load_model.assert_not_called()
+ celune._warmup.assert_not_called()
+ celune.voice_changed_callback.assert_called_once_with("bold")
+
def test_fatal_glow_marks_runtime_error_state(self) -> None:
"""Verify fatal glow always stamps Celune into the error state."""
celune = self._make_celune({})
@@ -472,113 +791,976 @@ def test_logging_waiting_and_api_settings_cover_edge_cases(self) -> None:
self.assertEqual(logs[-2][1], "warning")
self.assertEqual(logs[-1][1], "warning")
- def test_load_success_and_model_failure_paths_are_stubbed(self) -> None:
- """Verify successful startup and default-model failure handling.
-
- Raises:
- AssertionError: Startup behavior changes unexpectedly.
- """
+ def test_submit_audio_is_accepted_and_does_not_use_tts(self) -> None:
+ """Verify audio input is accepted without disturbing the text/TTS path."""
celune = self._make_celune({})
- celune.setup_extensions = mock.Mock()
- celune._warmup = mock.Mock(return_value=True)
- celune._start_configured_api = mock.Mock()
- celune.backend.preload_models = mock.Mock()
- celune.backend.load_default_model = mock.Mock(return_value={"model": "ok"})
- celune.backend.model_id_for_voice = mock.Mock(return_value="fake/balanced")
+ audio = np.ones((32, 2), dtype=np.float32)
+
with (
- mock.patch("celune.celune.threading.Thread") as thread_cls,
- mock.patch("celune.celune.validate_runtime", return_value=True),
- mock.patch("celune.celune.play_signal", return_value=False),
+ mock.patch("celune.celune.handle_audio_input", return_value=True) as handle,
+ mock.patch("celune.celune.say_pipeline", return_value=True) as say_pipeline,
):
- thread_cls.return_value.start = mock.Mock()
- self.assertEqual(celune.load(), True)
- self.assertEqual(celune.loaded, True)
- self.assertEqual(getattr(celune.glow, "entered"), True)
- celune.close()
+ self.assertEqual(celune.submit_audio(audio, 48000, label="fixture"), True)
+ self.assertEqual(celune.say("hello"), True)
+
+ handle.assert_called_once()
+ submitted_request = handle.call_args.args[1]
+ self.assertEqual(submitted_request.sample_rate, 48000)
+ self.assertEqual(submitted_request.label, "fixture")
+ self.assertEqual(submitted_request.audio.shape, (32, 2))
+ self.assertEqual(submitted_request.reset_ready_announcement, True)
+ say_pipeline.assert_called_once_with(
+ celune,
+ "hello",
+ save=True,
+ display_text=None,
+ )
- failing = self._make_celune({})
- failing.setup_extensions = mock.Mock()
- failing.backend.preload_models = mock.Mock()
- failing.backend.load_default_model = mock.Mock(side_effect=RuntimeError("boom"))
- errors: list[str] = []
- failing.error_callback = errors.append
- with mock.patch("celune.celune.play_signal", return_value=False):
- self.assertEqual(failing.load(), False)
- self.assertEqual(errors, ["Default model failed to load"])
- self.assertEqual(getattr(failing.glow, "fatal_called"), True)
+ def test_submit_audio_routes_to_vc_backend_in_voice_conversion_mode(self) -> None:
+ """Verify VC mode routes audio input through the configured VC backend."""
+ celune = self._make_celune({})
+ celune.input_mode = "voice_conversion"
+ celune.vc_backend = FakeVCBackend(log=lambda _msg, _severity="info": None)
+ audio = np.ones((24, 2), dtype=np.float32)
- def test_unload_runtime_state_clears_models_without_cuda(self) -> None:
- """Verify model references are cleared without touching CUDA.
+ with (
+ mock.patch(
+ "celune.celune.handle_audio_input", wraps=handle_audio_input
+ ) as handle,
+ mock.patch(
+ "celune.pipeline.queue_sfx_audio", return_value=True
+ ) as queue_sfx,
+ mock.patch("celune.celune.say_pipeline", return_value=True) as say_pipeline,
+ ):
+ self.assertEqual(celune.submit_audio(audio, 44100, label="fixture"), True)
- Raises:
- AssertionError: Unload behavior changes unexpectedly.
- """
+ handle.assert_called_once()
+ queue_sfx.assert_called_once()
+ say_pipeline.assert_not_called()
+
+ def test_convert_audio_returns_vc_output_without_queueing_playback(self) -> None:
+ """Verify direct conversion returns audio output without touching playback."""
celune = self._make_celune({})
- celune.model = cast(PreTrainedModel, mock.Mock(spec=PreTrainedModel))
- celune.llm = cast(PreTrainedModel, mock.Mock(spec=PreTrainedModel))
- celune.tokenizer = cast(
- PreTrainedTokenizerBase,
- mock.Mock(spec=PreTrainedTokenizerBase),
- )
- celune.backend.model = mock.Mock()
- with mock.patch("celune.celune.torch.cuda.is_available", return_value=False):
- celune.unload_runtime_state(include_normalizer=True)
- self.assertIsNone(celune.model)
- self.assertIsNone(celune.llm)
- self.assertIsNone(celune.tokenizer)
- self.assertIsNone(celune.backend.model)
+ celune.input_mode = "voice_conversion"
+ celune.vc_backend = FakeVCBackend(log=lambda _msg, _severity="info": None)
+ audio = np.ones((16, 2), dtype=np.float32)
- def test_unload_runtime_state_runs_model_close_hooks(self) -> None:
- """Verify TTS and normalizer teardown calls object-level release hooks."""
+ with (
+ mock.patch(
+ "celune.celune.convert_audio_input", wraps=convert_audio_input
+ ) as convert_input,
+ mock.patch(
+ "celune.pipeline.queue_sfx_audio", return_value=True
+ ) as queue_sfx,
+ ):
+ output = celune.convert_audio(audio, 32000, label="fixture")
+
+ self.assertIsNotNone(output)
+ self.assertEqual(output.sample_rate, 32000)
+ self.assertEqual(output.label, "fixture")
+ self.assertEqual(output.audio.shape, (16, 2))
+ convert_input.assert_called_once()
+ submitted_request = convert_input.call_args.args[1]
+ self.assertEqual(submitted_request.pitch_shift, None)
+ queue_sfx.assert_not_called()
+
+ def test_convert_audio_accepts_pitch_shift_override(self) -> None:
+ """Verify direct conversion forwards one pitch-shift override."""
celune = self._make_celune({})
- tts_model = SimpleNamespace(close=mock.Mock())
- llm = SimpleNamespace(close=mock.Mock())
- tokenizer = SimpleNamespace(close=mock.Mock())
- celune.model = cast(PreTrainedModel, tts_model)
- celune.backend.model = tts_model
- celune.llm = cast(PreTrainedModel, llm)
- celune.tokenizer = cast(PreTrainedTokenizerBase, tokenizer)
+ celune.input_mode = "voice_conversion"
+ celune.vc_backend = FakeVCBackend(log=lambda _msg, _severity="info": None)
+ audio = np.ones((16, 2), dtype=np.float32)
- with mock.patch("celune.celune.torch.cuda.is_available", return_value=False):
- celune.unload_runtime_state(include_normalizer=True)
+ with mock.patch(
+ "celune.celune.convert_audio_input", return_value=None
+ ) as convert_input:
+ celune.convert_audio(audio, 32000, label="fixture", pitch_shift=7)
- tts_model.close.assert_called_once_with()
- llm.close.assert_called_once_with()
- tokenizer.close.assert_called_once_with()
- self.assertIsNone(celune.model)
- self.assertIsNone(celune.backend.model)
- self.assertIsNone(celune.llm)
- self.assertIsNone(celune.tokenizer)
+ submitted_request = convert_input.call_args.args[1]
+ self.assertEqual(submitted_request.pitch_shift, 7)
- def test_stale_normalizer_load_does_not_restore_released_references(self) -> None:
- """Verify background normalizer loads cannot repopulate state after unload."""
+ def test_convert_audio_accepts_f0_condition_override(self) -> None:
+ """Verify direct conversion forwards one f0 conditioning override."""
celune = self._make_celune({})
- ready = threading.Event()
- release = threading.Event()
- finished = threading.Event()
- fake_tokenizer = cast(
- PreTrainedTokenizerBase,
- mock.Mock(spec=PreTrainedTokenizerBase),
- )
- fake_llm = cast(PreTrainedModel, mock.Mock(spec=PreTrainedModel))
+ celune.input_mode = "voice_conversion"
+ celune.vc_backend = FakeVCBackend(log=lambda _msg, _severity="info": None)
+ audio = np.ones((16, 2), dtype=np.float32)
- def fake_load_components(*_args, **_kwargs):
- ready.set()
- self.assertTrue(release.wait(timeout=2))
- finished.set()
- return fake_tokenizer, fake_llm
+ with mock.patch(
+ "celune.celune.convert_audio_input", return_value=None
+ ) as convert_input:
+ celune.convert_audio(audio, 32000, label="fixture", f0_condition=True)
+
+ submitted_request = convert_input.call_args.args[1]
+ self.assertEqual(submitted_request.f0_condition, True)
+ def test_constructor_reads_configured_vc_pitch_shift(self) -> None:
+ """Verify VC pitch shift is read from config during startup."""
with (
- mock.patch(
- "celune.celune.load_normalizer_components",
- side_effect=fake_load_components,
- ),
- mock.patch("celune.celune.torch.cuda.is_available", return_value=False),
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
):
- celune.load_normalizer()
- self.assertTrue(ready.wait(timeout=2))
- celune.unload_normalizer_state()
- release.set()
+ celune = Celune(
+ config={
+ "mode": "voice_conversion",
+ "voice_conversion_pitch_shift": -4,
+ },
+ tts_backend=FakeBackend,
+ )
+ self.addCleanup(self._close_celune, celune)
+
+ self.assertEqual(celune.vc_pitch_shift, -4)
+
+ def test_constructor_reads_configured_vc_f0_condition(self) -> None:
+ """Verify VC talk-vs-sing mode is read from config during startup."""
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ ):
+ celune = Celune(
+ config={
+ "mode": "voice_conversion",
+ "voice_conversion_f0_condition": True,
+ },
+ tts_backend=FakeBackend,
+ )
+ self.addCleanup(self._close_celune, celune)
+
+ self.assertEqual(celune.vc_f0_condition, True)
+
+ def test_convert_audio_rejects_text_to_speech_mode(self) -> None:
+ """Verify direct conversion stays unavailable outside VC mode."""
+ celune = self._make_celune({})
+ audio = np.ones((16, 2), dtype=np.float32)
+
+ with mock.patch("celune.celune.convert_audio_input") as convert_input:
+ output = celune.convert_audio(audio, 48000, label="fixture")
+
+ self.assertIsNone(output)
+ convert_input.assert_not_called()
+
+ def test_hot_backend_reload_failure_restores_previous_runtime(self) -> None:
+ """Verify failed backend reloads roll Celune back to the previous backend."""
+
+ class FailingBackend(FakeBackend):
+ """Backend fixture whose model load always fails."""
+
+ name = "failing"
+
+ def load_model(self, model_id: str, **kwargs: JSONSerializable):
+ raise RuntimeError("boom")
+
+ celune = self._make_celune({})
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.model = {"model_id": "fake/balanced", "kwargs": {}}
+ celune.backend.model = celune.model
+ celune.model_name = "fake/balanced"
+ celune.current_voice = "balanced"
+ celune.voices = ("balanced", "bold")
+
+ with mock.patch("celune.celune.play_signal", return_value=False):
+ self.assertEqual(
+ celune._hot_reload_backend(FailingBackend, "balanced"), False
+ )
+
+ self.assertEqual(celune.tts_backend, "fake")
+ self.assertEqual(celune.current_voice, "balanced")
+ self.assertEqual(celune.model_name, "fake/balanced")
+ self.assertEqual(celune.loaded, True)
+ self.assertEqual(celune.cur_state, "idle")
+
+ def test_hot_backend_reload_failure_reports_restore_status(self) -> None:
+ """Verify failed backend reloads announce the rollback phase."""
+
+ class FailingWarmupBackend(FakeBackend):
+ """Backend fixture whose warmup generation always fails."""
+
+ name = "failingwarmup"
+ voice_models = {"storm": "warmup/storm"}
+ default_voice = "storm"
+
+ def generate_stream(self, model, **kwargs: JSONSerializable):
+ discard(model)
+ discard(kwargs)
+ raise RuntimeError("warmup blew up")
+
+ celune = self._make_celune({})
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.model = {"model_id": "fake/balanced", "kwargs": {}}
+ celune.backend.model = celune.model
+ celune.model_name = "fake/balanced"
+ celune.current_voice = "balanced"
+ celune.voices = ("balanced", "bold")
+ celune.status_callback = mock.Mock()
+
+ with mock.patch("celune.celune.play_signal", return_value=False):
+ self.assertEqual(
+ celune._hot_reload_backend(FailingWarmupBackend, "storm"), False
+ )
+
+ status_calls = [call.args[0] for call in celune.status_callback.call_args_list]
+ self.assertIn("Warming up", status_calls)
+ self.assertIn("Restoring backend", status_calls)
+ self.assertEqual(status_calls[-1], "Idle")
+
+ def test_hot_backend_reload_unloads_previous_runtime_before_loading_new_one(
+ self,
+ ) -> None:
+ """Verify backend swaps release the old runtime before loading the next one."""
+
+ events: list[str] = []
+
+ class AltFakeBackend(FakeBackend):
+ """Alternative backend fixture used by unload-order tests."""
+
+ name = "altfake"
+ voice_models = {"storm": "alt/storm"}
+ default_voice = "storm"
+
+ def load_model(self, model_id: str, **kwargs: JSONSerializable):
+ events.append(f"load:{model_id}")
+ return super().load_model(model_id, **kwargs)
+
+ celune = self._make_celune({})
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.model = {"model_id": "fake/balanced", "kwargs": {}}
+ celune.backend.model = celune.model
+ celune.model_name = "fake/balanced"
+ celune.current_voice = "balanced"
+ celune.voices = ("balanced", "bold")
+ celune._warmup = mock.Mock(return_value=True)
+ original_unload = celune.backend.unload_model
+
+ def record_unload() -> None:
+ events.append("unload:fake")
+ original_unload()
+
+ celune.backend.unload_model = mock.Mock(side_effect=record_unload)
+
+ with mock.patch("celune.celune.play_signal", return_value=False):
+ self.assertEqual(celune._hot_reload_backend(AltFakeBackend, "storm"), True)
+
+ self.assertEqual(events[:2], ["unload:fake", "load:alt/storm"])
+
+ def test_set_backend_marks_reload_pending_before_playing_working_signal(
+ self,
+ ) -> None:
+ """Verify backend switching marks reload pending before the transition signal."""
+ celune = self._make_celune({})
+ celune.cur_state = "idle"
+ celune.loaded = True
+ celune.change_input_state_callback = mock.Mock()
+ celune.change_voice_lock_state_callback = mock.Mock()
+ signal_states: list[tuple[str, str, bool, bool]] = []
+
+ def record_signal(signal_type: str) -> bool:
+ signal_states.append(
+ (signal_type, celune.cur_state, celune.loaded, celune._reload_pending)
+ )
+ return True
+
+ celune._try_play_signal = mock.Mock(side_effect=record_signal)
+
+ with mock.patch("celune.celune.threading.Thread") as thread_cls:
+ self.assertEqual(celune.set_backend("mini"), True)
+
+ self.assertEqual(signal_states, [("working", "idle", True, True)])
+ celune.change_input_state_callback.assert_called_once_with(locked=True)
+ celune.change_voice_lock_state_callback.assert_called_once_with(locked=True)
+ self.assertEqual(celune.cur_state, "idle")
+ self.assertEqual(celune.loaded, True)
+ self.assertEqual(celune._reload_pending, True)
+ self.assertEqual(celune._model_ready.is_set(), False)
+ thread_cls.return_value.start.assert_called_once_with()
+
+ def test_set_backend_and_wait_failure_keeps_previous_runtime_loaded(self) -> None:
+ """Verify failed backend switches preserve the previously loaded backend runtime."""
+
+ class FailingBackend(FakeBackend):
+ """Backend fixture whose model load always fails."""
+
+ name = "failing"
+
+ def load_model(self, model_id: str, **kwargs: JSONSerializable):
+ raise RuntimeError("boom")
+
+ celune = self._make_celune({})
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.model = {"model_id": "fake/balanced", "kwargs": {}}
+ celune.backend.model = celune.model
+ celune.model_name = "fake/balanced"
+ celune.current_voice = "balanced"
+ celune.voices = ("balanced", "bold")
+
+ with (
+ mock.patch(
+ "celune.celune.threading.Thread", side_effect=self._immediate_thread
+ ),
+ mock.patch("celune.celune.play_signal", return_value=False),
+ ):
+ self.assertEqual(celune.set_backend_and_wait(FailingBackend), False)
+
+ self.assertEqual(celune.tts_backend, "fake")
+ self.assertEqual(celune.current_voice, "balanced")
+ self.assertEqual(celune.model_name, "fake/balanced")
+ self.assertEqual(celune.loaded, True)
+ self.assertIsNotNone(celune.model)
+ self.assertIs(celune.backend.model, celune.model)
+
+ def test_set_backend_and_wait_recovers_when_reload_setup_crashes(self) -> None:
+ """Verify backend reload setup failures still release the waiting caller."""
+ celune = self._make_celune({})
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.model = {"model_id": "fake/balanced", "kwargs": {}}
+ celune.backend.model = celune.model
+ celune.model_name = "fake/balanced"
+ celune.current_voice = "balanced"
+ celune.voices = ("balanced", "bold")
+
+ with (
+ mock.patch(
+ "celune.celune.threading.Thread", side_effect=self._immediate_thread
+ ),
+ mock.patch.object(
+ celune,
+ "_backend_reload_kwargs",
+ side_effect=RuntimeError("setup blew up"),
+ ),
+ ):
+ self.assertEqual(celune.set_backend_and_wait("mini", timeout=0.1), False)
+
+ self.assertEqual(celune.cur_state, "idle")
+ self.assertEqual(celune.loaded, True)
+ self.assertEqual(celune._reload_pending, False)
+ self.assertEqual(celune._model_ready.is_set(), True)
+ self.assertEqual(celune.tts_backend, "fake")
+ self.assertEqual(celune.current_voice, "balanced")
+
+ def test_set_backend_rejects_reentrant_reload_requests(self) -> None:
+ """Verify a second backend switch is refused while reloading is already active."""
+ celune = self._make_celune({})
+ celune.cur_state = "reloading"
+ celune.change_input_state_callback = mock.Mock()
+ celune.change_voice_lock_state_callback = mock.Mock()
+ celune.log_callback = mock.Mock()
+
+ with mock.patch("celune.celune.threading.Thread") as thread_cls:
+ self.assertEqual(celune.set_backend("mini"), False)
+
+ thread_cls.assert_not_called()
+ celune.change_input_state_callback.assert_not_called()
+ celune.change_voice_lock_state_callback.assert_not_called()
+ celune.log_callback.assert_called_once_with(
+ "A backend or character reload is already in progress.",
+ "warning",
+ )
+
+ def test_set_backend_and_wait_uses_unbounded_wait_by_default(self) -> None:
+ """Verify direct backend switches wait indefinitely unless a timeout is supplied."""
+ celune = self._make_celune({})
+ celune.loaded = True
+ celune.set_backend = mock.Mock(return_value=True)
+ celune._model_ready.wait = mock.Mock(return_value=True)
+ celune._active_runtime_backend_name = mock.Mock(return_value="mini")
+
+ self.assertEqual(celune.set_backend_and_wait("mini"), True)
+
+ celune.set_backend.assert_called_once_with("mini")
+ celune._model_ready.wait.assert_called_once_with(timeout=None)
+ celune._active_runtime_backend_name.assert_called_once_with()
+
+ def test_set_backend_and_wait_can_switch_between_tts_and_vc_backends(self) -> None:
+ """Verify backend hot reloads can move across the TTS and VC backend families."""
+
+ class CountingBackend(FakeBackend):
+ """Fake TTS backend that records unload requests."""
+
+ name = "mini"
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.unload_calls = 0
+
+ def unload_model(self) -> None:
+ self.unload_calls += 1
+ super().unload_model()
+
+ class CountingVCBackend(FakeVCBackend):
+ """Fake VC backend that records preload and unload requests."""
+
+ name = "counting-vc"
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.preload_calls = 0
+ self.unload_calls = 0
+
+ def preload_models(self) -> None:
+ self.preload_calls += 1
+
+ def unload_model(self) -> None:
+ self.unload_calls += 1
+
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ mock.patch(
+ "celune.celune.threading.Thread", side_effect=self._immediate_thread
+ ),
+ mock.patch("celune.celune.play_signal", return_value=False),
+ ):
+ celune = Celune(config={}, tts_backend=CountingBackend)
+ self.addCleanup(self._close_celune, celune)
+
+ initial_backend = cast(CountingBackend, celune.backend)
+ celune.loaded = True
+ celune.model = {"model_id": "counting/balanced", "kwargs": {}}
+ celune.backend.model = celune.model
+ celune.model_name = "counting/balanced"
+ celune.current_voice = "balanced"
+ celune.voices = ("balanced", "bold")
+
+ self.assertEqual(celune.set_backend_and_wait(CountingVCBackend), True)
+
+ switched_vc_backend = cast(CountingVCBackend, celune.vc_backend)
+ self.assertEqual(initial_backend.unload_calls, 1)
+ self.assertEqual(switched_vc_backend.preload_calls, 1)
+ self.assertEqual(celune.input_mode, "voice_conversion")
+ self.assertEqual(celune._active_runtime_backend_name(), "counting-vc")
+ self.assertIsNone(celune.model)
+ self.assertEqual(celune.model_name, "")
+
+ self.assertEqual(celune.set_backend_and_wait(FakeBackend), True)
+
+ self.assertEqual(switched_vc_backend.unload_calls, 1)
+ self.assertEqual(celune.input_mode, "text_to_speech")
+ self.assertIsNone(celune.vc_backend)
+ self.assertEqual(celune._active_runtime_backend_name(), "fake")
+ self.assertEqual(celune.tts_backend, "fake")
+ self.assertIsNotNone(celune.model)
+
+ def test_set_backend_and_wait_restores_vc_runtime_after_failed_tts_switch(
+ self,
+ ) -> None:
+ """Verify failed VC-to-TTS switches rebuild the previous VC runtime."""
+
+ class CountingVCBackend(FakeVCBackend):
+ """Fake VC backend that records lifecycle operations."""
+
+ name = "counting-vc"
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.preload_calls = 0
+ self.unload_calls = 0
+
+ def preload_models(self) -> None:
+ self.preload_calls += 1
+
+ def unload_model(self) -> None:
+ self.unload_calls += 1
+
+ class FailingBackend(FakeBackend):
+ """Backend fixture whose model load always fails."""
+
+ name = "failing"
+
+ def load_model(self, model_id: str, **kwargs: JSONSerializable):
+ raise RuntimeError("boom")
+
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ mock.patch(
+ "celune.celune.threading.Thread", side_effect=self._immediate_thread
+ ),
+ mock.patch("celune.celune.play_signal", return_value=False),
+ ):
+ celune = Celune(config={}, tts_backend=FakeBackend)
+ self.addCleanup(self._close_celune, celune)
+
+ self.assertEqual(celune.set_backend_and_wait(CountingVCBackend), True)
+ previous_vc_backend = cast(CountingVCBackend, celune.vc_backend)
+
+ self.assertEqual(celune.set_backend_and_wait(FailingBackend), False)
+
+ self.assertEqual(previous_vc_backend.unload_calls, 1)
+ self.assertEqual(celune.input_mode, "voice_conversion")
+ self.assertIsNotNone(celune.vc_backend)
+ self.assertIsInstance(celune.vc_backend, CountingVCBackend)
+ self.assertIsNot(celune.vc_backend, previous_vc_backend)
+ self.assertEqual(celune._active_runtime_backend_name(), "counting-vc")
+ self.assertEqual(celune.voice_conversion_backend, "counting-vc")
+
+ def test_set_backend_rejects_unknown_backend_before_reload_side_effects(
+ self,
+ ) -> None:
+ """Verify unknown backend names do not trigger reload state or working signals."""
+ celune = self._make_celune({})
+ celune.cur_state = "idle"
+ celune.loaded = True
+ celune.change_input_state_callback = mock.Mock()
+ celune.change_voice_lock_state_callback = mock.Mock()
+ celune.log_callback = mock.Mock()
+ celune._try_play_signal = mock.Mock()
+
+ with mock.patch("celune.celune.threading.Thread") as thread_cls:
+ self.assertEqual(celune.set_backend("qwen"), False)
+
+ thread_cls.assert_not_called()
+ celune.change_input_state_callback.assert_not_called()
+ celune.change_voice_lock_state_callback.assert_not_called()
+ celune._try_play_signal.assert_not_called()
+ celune.log_callback.assert_called_once_with(
+ "Unknown backend: qwen (available: mini, qwen3, dotstts, voxcpm2, passthrough, seed-vc)",
+ "warning",
+ )
+ self.assertEqual(celune.cur_state, "idle")
+ self.assertEqual(celune.loaded, True)
+ self.assertEqual(celune._reload_pending, False)
+
+ def test_set_backend_unknown_name_keeps_previous_runtime_live(self) -> None:
+ """Verify invalid backend names leave the previously loaded backend fully usable."""
+ celune = self._make_celune({})
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.model = {"model_id": "fake/balanced", "kwargs": {}}
+ celune.backend.model = celune.model
+ celune.model_name = "fake/balanced"
+ celune.current_voice = "balanced"
+
+ self.assertEqual(celune.set_backend_and_wait("qwen"), False)
+
+ self.assertEqual(celune.backend.name, "fake")
+ self.assertEqual(celune.tts_backend, "fake")
+ self.assertEqual(celune.model_name, "fake/balanced")
+ self.assertEqual(celune.current_voice, "balanced")
+ self.assertEqual(celune.loaded, True)
+ self.assertIs(celune.backend.model, celune.model)
+ self.assertEqual(celune._reload_pending, False)
+
+ def test_set_cevoice_rejects_reentrant_reload_requests(self) -> None:
+ """Verify a second character switch is refused while reloading is already active."""
+ celune = self._make_celune({})
+ celune.cur_state = "reloading"
+ celune.change_input_state_callback = mock.Mock()
+ celune.change_voice_lock_state_callback = mock.Mock()
+ celune.log_callback = mock.Mock()
+
+ with mock.patch("celune.celune.threading.Thread") as thread_cls:
+ self.assertEqual(celune.set_cevoice("nova"), False)
+
+ thread_cls.assert_not_called()
+ celune.change_input_state_callback.assert_not_called()
+ celune.change_voice_lock_state_callback.assert_not_called()
+ celune.log_callback.assert_called_once_with(
+ "A backend or character reload is already in progress.",
+ "warning",
+ )
+
+ def test_set_cevoice_and_wait_uses_unbounded_wait_by_default(self) -> None:
+ """Verify direct character switches wait indefinitely unless a timeout is supplied."""
+ celune = self._make_celune({})
+ target_bundle = Path("celune.cevoice")
+ celune.loaded = True
+ celune.set_cevoice = mock.Mock(return_value=True)
+ celune._model_ready.wait = mock.Mock(return_value=True)
+
+ with (
+ mock.patch("celune.celune.active_bundle_path", return_value=target_bundle),
+ mock.patch("celune.celune.resolve_bundle_path", return_value=target_bundle),
+ ):
+ self.assertEqual(celune.set_cevoice_and_wait(target_bundle), True)
+
+ celune.set_cevoice.assert_called_once_with(target_bundle)
+ celune._model_ready.wait.assert_called_once_with(timeout=None)
+
+ def test_set_cevoice_rejects_missing_bundle_before_reload_side_effects(
+ self,
+ ) -> None:
+ """Verify missing CEVOICE bundles do not start a reload or lock the UI."""
+ celune = self._make_celune({})
+ celune.change_input_state_callback = mock.Mock()
+ celune.change_voice_lock_state_callback = mock.Mock()
+ celune.log_callback = mock.Mock()
+
+ with mock.patch("celune.celune.threading.Thread") as thread_cls:
+ self.assertEqual(celune.set_cevoice("invalid_character"), False)
+
+ thread_cls.assert_not_called()
+ celune.change_input_state_callback.assert_not_called()
+ celune.change_voice_lock_state_callback.assert_not_called()
+ celune.log_callback.assert_called_once_with(
+ "Voice pack not found: invalid_character",
+ "warning",
+ )
+ self.assertEqual(celune._reload_pending, False)
+
+ def test_hot_backend_reload_warmup_does_not_publish_candidate_backend_early(
+ self,
+ ) -> None:
+ """Verify candidate backend warmup runs before the live backend pointer is swapped."""
+ celune = self._make_celune({})
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.model = {"model_id": "fake/balanced", "kwargs": {}}
+ celune.backend.model = celune.model
+ celune.model_name = "fake/balanced"
+ celune.current_voice = "balanced"
+ celune.voices = ("balanced", "bold")
+ observed_backend_names: list[str] = []
+
+ class FailingWarmupBackend(FakeBackend):
+ """Backend fixture whose warmup generation fails after observing live state."""
+
+ name = "failingwarmup"
+ voice_models = {"storm": "warmup/storm"}
+ default_voice = "storm"
+
+ def generate_stream(self, model, **kwargs: JSONSerializable):
+ observed_backend_names.append(celune.backend.name)
+ discard(model)
+ discard(kwargs)
+ raise RuntimeError("warmup blew up")
+
+ with mock.patch("celune.celune.play_signal", return_value=False):
+ self.assertEqual(
+ celune._hot_reload_backend(FailingWarmupBackend, "storm"), False
+ )
+
+ self.assertEqual(observed_backend_names, ["fake"])
+ self.assertEqual(celune.backend.name, "fake")
+ self.assertEqual(celune.loaded, True)
+
+ def test_with_backend_temporarily_switches_and_restores_backend(self) -> None:
+ """Verify the backend context manager restores the original backend after use."""
+
+ class AltFakeBackend(FakeBackend):
+ """Alternative backend fixture used by context-manager tests."""
+
+ name = "altfake"
+ voice_models = {"storm": "alt/storm", "calm": "alt/calm"}
+ default_voice = "storm"
+
+ celune = self._make_celune({})
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.model = {"model_id": "fake/balanced", "kwargs": {}}
+ celune.backend.model = celune.model
+ celune.model_name = "fake/balanced"
+ celune.current_voice = "balanced"
+ celune.voices = ("balanced", "bold")
+ celune.locked = False
+ celune.model_ready.set()
+ celune.playback_done.set()
+ celune._warmup = mock.Mock(return_value=True)
+
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(
+ mock.patch("celune.celune.play_signal", return_value=False)
+ )
+ with celune.with_backend(AltFakeBackend):
+ self.assertEqual(celune.tts_backend, "altfake")
+ self.assertEqual(celune.current_voice, "storm")
+ self.assertEqual(celune.model_name, "alt/storm")
+
+ self.assertEqual(celune.tts_backend, "fake")
+ self.assertEqual(celune.current_voice, "balanced")
+ self.assertEqual(celune.model_name, "fake/balanced")
+
+ def test_with_backend_does_not_pin_old_backend_instance_during_override(
+ self,
+ ) -> None:
+ """Verify temporary backend overrides do not keep the old backend instance alive."""
+
+ class AltFakeBackend(FakeBackend):
+ """Alternative backend fixture used by backend lifetime tests."""
+
+ name = "altfake"
+ voice_models = {"storm": "alt/storm"}
+ default_voice = "storm"
+
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ ):
+ original_backend = FakeBackend(log=lambda _msg, _severity="info": None)
+ backend_ref = weakref.ref(original_backend)
+ celune = Celune(config={}, tts_backend=original_backend)
+ self.addCleanup(self._close_celune, celune)
+
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.model = {"model_id": "fake/balanced", "kwargs": {}}
+ celune.backend.model = celune.model
+ celune.model_name = "fake/balanced"
+ celune.current_voice = "balanced"
+ celune.voices = ("balanced", "bold")
+ celune.locked = False
+ celune.model_ready.set()
+ celune.playback_done.set()
+ celune._warmup = mock.Mock(return_value=True)
+ del original_backend
+
+ with mock.patch("celune.celune.play_signal", return_value=False):
+ with celune.with_backend(AltFakeBackend):
+ import gc as _gc
+
+ _gc.collect()
+ self.assertIsNone(backend_ref())
+
+ def test_hot_cevoice_reload_failure_restores_previous_bundle_state(self) -> None:
+ """Verify failed CEVOICE reloads restore the previous bundle and voice state."""
+ celune = self._make_celune({})
+ celune.backend.uses_voice_bundles = True
+ celune.backend.validate_refs = mock.Mock()
+ celune.backend.model_id_for_voice = mock.Mock(
+ side_effect=lambda voice: f"fake/{voice}"
+ )
+ celune.backend.load_model = mock.Mock(
+ side_effect=lambda model_id: {"model_id": model_id}
+ )
+ celune._warmup = mock.Mock(return_value=True)
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.model = {"model_id": "fake/balanced"}
+ celune.backend.model = celune.model
+ celune.model_name = "fake/balanced"
+ celune.current_voice = "balanced"
+ celune.current_character = "Celune"
+ celune.voices = ("balanced", "bold")
+
+ selected = {"path": Path("celune.cevoice")}
+ first_bundle = SimpleNamespace(
+ path=Path("celune.cevoice"),
+ voice_order=("balanced", "bold"),
+ metadata={"name": "Celune", "default_voice": "balanced"},
+ )
+
+ def fake_select(bundle=None):
+ selected["path"] = (
+ Path(bundle) if bundle is not None else Path("celune.cevoice")
+ )
+ return selected["path"]
+
+ def fake_loader():
+ if selected["path"].name == "celune.cevoice":
+ return SimpleNamespace(bundle=first_bundle)
+ if selected["path"].name == "broken.cevoice":
+ return SimpleNamespace(
+ bundle=SimpleNamespace(
+ path=Path("broken.cevoice"),
+ voice_order=(),
+ metadata={"name": "Broken"},
+ )
+ )
+ return None
+
+ with (
+ mock.patch("celune.celune.select_voice_bundle", side_effect=fake_select),
+ mock.patch("celune.celune.default_loader", side_effect=fake_loader),
+ mock.patch(
+ "celune.celune.bundle_matches_default_pack_checksum",
+ side_effect=lambda path: Path(path).name == "celune.cevoice",
+ ),
+ mock.patch(
+ "celune.celune.active_bundle_path", side_effect=lambda: selected["path"]
+ ),
+ mock.patch("celune.celune.play_signal", return_value=False),
+ ):
+ self.assertEqual(celune._hot_reload_cevoice(Path("broken.cevoice")), False)
+
+ self.assertEqual(selected["path"], Path("celune.cevoice"))
+ self.assertEqual(celune.current_character, "Celune")
+ self.assertEqual(celune.current_voice, "balanced")
+ self.assertEqual(celune.model_name, "fake/balanced")
+ self.assertEqual(celune.loaded, True)
+
+ def test_set_cevoice_and_wait_recovers_when_reload_setup_crashes(self) -> None:
+ """Verify CEVOICE reload setup failures still release the waiting caller."""
+ celune = self._make_celune({})
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.current_voice = "balanced"
+
+ with (
+ mock.patch(
+ "celune.celune.threading.Thread", side_effect=self._immediate_thread
+ ),
+ mock.patch(
+ "celune.celune.default_loader", side_effect=RuntimeError("boom")
+ ),
+ ):
+ self.assertEqual(
+ celune.set_cevoice_and_wait(Path("broken.cevoice"), timeout=0.1), False
+ )
+
+ self.assertEqual(celune.cur_state, "idle")
+ self.assertEqual(celune.loaded, True)
+ self.assertEqual(celune._reload_pending, False)
+ self.assertEqual(celune._model_ready.is_set(), True)
+ self.assertEqual(celune.current_voice, "balanced")
+
+ def test_voice_conversion_mode_rejects_text_input(self) -> None:
+ """Verify VC mode rejects text input instead of using the TTS backend."""
+ celune = self._make_celune({})
+ celune.input_mode = "voice_conversion"
+
+ with mock.patch(
+ "celune.celune.say_pipeline", return_value=True
+ ) as say_pipeline:
+ self.assertEqual(celune.say("hello"), False)
+
+ say_pipeline.assert_not_called()
+
+ def test_constructor_accepts_passthrough_vc_backend_in_voice_conversion_mode(
+ self,
+ ) -> None:
+ """Verify VC mode resolves the default passthrough backend cleanly."""
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ ):
+ celune = Celune(
+ config={"mode": "voice_conversion"}, tts_backend=FakeBackend
+ )
+ self.addCleanup(self._close_celune, celune)
+
+ self.assertEqual(celune.input_mode, "voice_conversion")
+ self.assertEqual(celune.voice_conversion_backend, "passthrough")
+
+ def test_constructor_rejects_unknown_vc_backend_cleanly(self) -> None:
+ """Verify unsupported VC backends surface a readable backend error."""
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ self.assertRaisesRegex(BackendError, "unknown voice-conversion backend"),
+ ):
+ Celune(
+ config={"mode": "voice_conversion"},
+ tts_backend=FakeBackend,
+ vc_backend="missing",
+ )
+
+ def test_load_success_and_model_failure_paths_are_stubbed(self) -> None:
+ """Verify successful startup and default-model failure handling.
+
+ Raises:
+ AssertionError: Startup behavior changes unexpectedly.
+ """
+ celune = self._make_celune({})
+ celune.setup_extensions = mock.Mock()
+ celune._warmup = mock.Mock(return_value=True)
+ celune._start_configured_api = mock.Mock()
+ celune.backend.preload_models = mock.Mock()
+ celune.backend.load_default_model = mock.Mock(return_value={"model": "ok"})
+ celune.backend.model_id_for_voice = mock.Mock(return_value="fake/balanced")
+ with (
+ mock.patch("celune.celune.threading.Thread") as thread_cls,
+ mock.patch("celune.celune.validate_runtime", return_value=True),
+ mock.patch("celune.celune.play_signal", return_value=False),
+ ):
+ thread_cls.return_value.start = mock.Mock()
+ self.assertEqual(celune.load(), True)
+ self.assertEqual(celune.loaded, True)
+ self.assertEqual(getattr(celune.glow, "entered"), True)
+ celune.close()
+
+ failing = self._make_celune({})
+ failing.setup_extensions = mock.Mock()
+ failing.backend.preload_models = mock.Mock()
+ failing.backend.load_default_model = mock.Mock(side_effect=RuntimeError("boom"))
+ errors: list[str] = []
+ failing.error_callback = errors.append
+ with mock.patch("celune.celune.play_signal", return_value=False):
+ self.assertEqual(failing.load(), False)
+ self.assertEqual(errors, ["Default model failed to load"])
+ self.assertEqual(getattr(failing.glow, "fatal_called"), True)
+
+ def test_unload_runtime_state_clears_models_without_cuda(self) -> None:
+ """Verify model references are cleared without touching CUDA.
+
+ Raises:
+ AssertionError: Unload behavior changes unexpectedly.
+ """
+ celune = self._make_celune({})
+ celune.model = cast(PreTrainedModel, mock.Mock(spec=PreTrainedModel))
+ celune.llm = cast(PreTrainedModel, mock.Mock(spec=PreTrainedModel))
+ celune.tokenizer = cast(
+ PreTrainedTokenizerBase,
+ mock.Mock(spec=PreTrainedTokenizerBase),
+ )
+ celune.backend.model = mock.Mock()
+ with mock.patch("celune.celune.torch.cuda.is_available", return_value=False):
+ celune.unload_runtime_state(include_normalizer=True)
+ self.assertIsNone(celune.model)
+ self.assertIsNone(celune.llm)
+ self.assertIsNone(celune.tokenizer)
+ self.assertIsNone(celune.backend.model)
+
+ def test_unload_runtime_state_runs_model_close_hooks(self) -> None:
+ """Verify TTS and normalizer teardown calls object-level release hooks."""
+ celune = self._make_celune({})
+ tts_model = SimpleNamespace(close=mock.Mock())
+ llm = SimpleNamespace(close=mock.Mock())
+ tokenizer = SimpleNamespace(close=mock.Mock())
+ celune.model = cast(PreTrainedModel, tts_model)
+ celune.backend.model = tts_model
+ celune.llm = cast(PreTrainedModel, llm)
+ celune.tokenizer = cast(PreTrainedTokenizerBase, tokenizer)
+
+ with mock.patch("celune.celune.torch.cuda.is_available", return_value=False):
+ celune.unload_runtime_state(include_normalizer=True)
+
+ tts_model.close.assert_called_once_with()
+ llm.close.assert_called_once_with()
+ tokenizer.close.assert_called_once_with()
+ self.assertIsNone(celune.model)
+ self.assertIsNone(celune.backend.model)
+ self.assertIsNone(celune.llm)
+ self.assertIsNone(celune.tokenizer)
+
+ def test_stale_normalizer_load_does_not_restore_released_references(self) -> None:
+ """Verify background normalizer loads cannot repopulate state after unload."""
+ celune = self._make_celune({})
+ ready = threading.Event()
+ release = threading.Event()
+ finished = threading.Event()
+ fake_tokenizer = cast(
+ PreTrainedTokenizerBase,
+ mock.Mock(spec=PreTrainedTokenizerBase),
+ )
+ fake_llm = cast(PreTrainedModel, mock.Mock(spec=PreTrainedModel))
+
+ def fake_load_components(*_args, **_kwargs):
+ ready.set()
+ self.assertTrue(release.wait(timeout=2))
+ finished.set()
+ return fake_tokenizer, fake_llm
+
+ with (
+ mock.patch(
+ "celune.celune.load_normalizer_components",
+ side_effect=fake_load_components,
+ ),
+ mock.patch("celune.celune.torch.cuda.is_available", return_value=False),
+ ):
+ celune.load_normalizer()
+ self.assertTrue(ready.wait(timeout=2))
+ celune.unload_normalizer_state()
+ release.set()
self.assertTrue(finished.wait(timeout=2))
self.assertIsNone(celune.llm)
@@ -774,3 +1956,23 @@ def test_raise_warmup_error_preserves_original_cause(self) -> None:
celune.raise_warmup_error("warmup failed after sleep")
self.assertIs(exc_info.exception.__cause__, cause)
+
+ def test_nonfatal_warmup_failure_does_not_enter_fatal_error_state(self) -> None:
+ """Verify rollback-scoped warmup failures stay non-fatal."""
+ celune = self._make_celune({})
+ celune.cur_state = "reloading"
+ celune.loaded = True
+ celune.model = {"model_id": "fake/balanced", "kwargs": {}}
+ celune.backend.model = celune.model
+ celune.error_callback = mock.Mock()
+ celune.backend.generate_stream = mock.Mock(
+ side_effect=RuntimeError("warmup blew up")
+ )
+
+ result = celune._warmup(fatal_on_failure=False)
+
+ self.assertEqual(result, False)
+ self.assertEqual(celune.cur_state, "reloading")
+ self.assertEqual(celune.loaded, True)
+ self.assertEqual(getattr(celune.glow, "fatal_called"), False)
+ celune.error_callback.assert_not_called()
diff --git a/tests/test_cevoice.py b/tests/test_cevoice.py
index 7f06bd2..d80eb4a 100644
--- a/tests/test_cevoice.py
+++ b/tests/test_cevoice.py
@@ -146,6 +146,22 @@ def test_write_open_read_and_materialize_bundle_assets(self) -> None:
self.assertEqual(magic, cevoice.MAGIC)
self.assertEqual(version, cevoice.VERSION)
+ def test_materialize_recreates_missing_cached_asset_after_cleanup(self) -> None:
+ """Verify cached CEVOICE temp assets are rebuilt if cleanup removed them."""
+ bundle = self._write_bundle()
+ loader = cevoice.CEVoiceLoader(bundle)
+ self.addCleanup(loader.close)
+
+ first_path = loader.materialize("balanced", "wav")
+ self.assertEqual(first_path.read_bytes(), b"wav")
+
+ shutil.rmtree(first_path.parent)
+ rebuilt_path = loader.materialize("balanced", "wav")
+
+ self.assertEqual(rebuilt_path, first_path)
+ self.assertTrue(rebuilt_path.is_file())
+ self.assertEqual(rebuilt_path.read_bytes(), b"wav")
+
def test_legacy_cevoice_v1_bundle_remains_loadable(self) -> None:
"""Verify legacy CEVOICE v1 bundles still open after the schema rename."""
bundle = self._write_bundle()
diff --git a/tests/test_config_and_utils.py b/tests/test_config_and_utils.py
index 9144b29..01b59f5 100644
--- a/tests/test_config_and_utils.py
+++ b/tests/test_config_and_utils.py
@@ -50,6 +50,110 @@ def test_config_value_and_config_bool_precedence(self) -> None:
True,
)
+ def test_config_audio_device_supports_null_name_and_index(self) -> None:
+ """Verify audio-device config accepts null, trimmed names, and indices."""
+ self.assertIsNone(config.config_audio_device({}, "missing"))
+ self.assertIsNone(config.config_audio_device({"device": None}, "device"))
+ self.assertIsNone(config.config_audio_device({"device": " "}, "device"))
+ self.assertEqual(
+ config.config_audio_device({"device": " Stereo Mix "}, "device"),
+ "Stereo Mix",
+ )
+ self.assertEqual(config.config_audio_device({"device": 3}, "device"), 3)
+ self.assertIsNone(config.config_audio_device({"device": True}, "device"))
+
+ def test_resolve_audio_device_formats_friendly_multiple_input_matches(self) -> None:
+ """Verify ambiguous input device names raise a friendly localized message."""
+ devices = [
+ {
+ "name": "CABLE-A Output (VB-Audio Cable A)",
+ "max_input_channels": 2,
+ "max_output_channels": 0,
+ "hostapi": 0,
+ },
+ {
+ "name": "CABLE-A Output (VB-Audio Cable A)",
+ "max_input_channels": 8,
+ "max_output_channels": 0,
+ "hostapi": 1,
+ },
+ ]
+ hostapis = [{"name": "MME"}, {"name": "Windows WASAPI"}]
+
+ with (
+ mock.patch("celune.config.sd.query_devices", return_value=devices),
+ mock.patch("celune.config.sd.query_hostapis", return_value=hostapis),
+ ):
+ with self.assertRaisesRegex(
+ ValueError,
+ "The specified input device name has multiple matches",
+ ) as caught:
+ config.resolve_audio_device(
+ {"input_device": "CABLE-A Output"},
+ "input_device",
+ "input",
+ )
+
+ message = str(caught.exception)
+ self.assertIn("- [0] CABLE-A Output (VB-Audio Cable A), MME", message)
+ self.assertIn(
+ "- [1] CABLE-A Output (VB-Audio Cable A), Windows WASAPI",
+ message,
+ )
+ self.assertIn("Please specify one of the above devices", message)
+
+ def test_resolve_audio_device_prefers_exact_single_output_index(self) -> None:
+ """Verify one matching output device is resolved to its exact index."""
+ devices = [
+ {
+ "name": "Speakers",
+ "max_input_channels": 0,
+ "max_output_channels": 2,
+ "hostapi": 0,
+ },
+ {
+ "name": "CABLE-B Input (VB-Audio Cable B)",
+ "max_input_channels": 0,
+ "max_output_channels": 2,
+ "hostapi": 1,
+ },
+ ]
+ hostapis = [{"name": "MME"}, {"name": "Windows WASAPI"}]
+
+ with (
+ mock.patch("celune.config.sd.query_devices", return_value=devices),
+ mock.patch("celune.config.sd.query_hostapis", return_value=hostapis),
+ ):
+ resolved = config.resolve_audio_device(
+ {"output_device": "CABLE-B Input"},
+ "output_device",
+ "output",
+ )
+
+ self.assertEqual(resolved, 1)
+
+ def test_resolve_audio_device_accepts_single_device_mapping_shape(self) -> None:
+ """Verify resolver tolerates a direct device-info mapping from mocks."""
+ device_info = {
+ "name": "Stereo Mix (Realtek)",
+ "max_input_channels": 2,
+ "max_output_channels": 0,
+ "hostapi": 0,
+ }
+ hostapis = [{"name": "Windows WASAPI"}]
+
+ with (
+ mock.patch("celune.config.sd.query_devices", return_value=device_info),
+ mock.patch("celune.config.sd.query_hostapis", return_value=hostapis),
+ ):
+ resolved = config.resolve_audio_device(
+ {"input_device": "Stereo Mix (Realtek)"},
+ "input_device",
+ "input",
+ )
+
+ self.assertEqual(resolved, "Stereo Mix (Realtek)")
+
def test_merge_missing_defaults_preserves_user_values_and_adds_nested_keys(
self,
) -> None:
diff --git a/tests/test_extension_events.py b/tests/test_extension_events.py
new file mode 100644
index 0000000..6148c07
--- /dev/null
+++ b/tests/test_extension_events.py
@@ -0,0 +1,361 @@
+# SPDX-License-Identifier: MIT
+"""Tests for Celune's extension event subsystem."""
+
+import sys
+import tempfile
+import textwrap
+from pathlib import Path
+from types import SimpleNamespace
+from typing import cast
+from unittest import TestCase, mock
+
+from celune import subscribe
+from celune.celune import Celune
+from celune.dataclasses.events import (
+ AudioEndEvent,
+ AudioStartEvent,
+ CharacterChangedEvent,
+ CharacterLoadedEvent,
+ CharacterUnloadedEvent,
+ ReadyEvent,
+ ShutdownEvent,
+ StateChangedEvent,
+ VoiceChangedEvent,
+)
+from celune.dataclasses.extensions import CeluneContext
+from celune.extensions.base import CeluneExtension
+from celune.extensions.events import EventDispatcher, iter_subscriptions
+from celune.extensions.manager import CeluneExtensionManager
+from celune.typing.events import ReadyEventCallback
+from .support import FakeBackend, FakeGlow
+
+
+class DispatcherTests(TestCase):
+ """Tests for low-level event dispatch behavior."""
+
+ def setUp(self) -> None:
+ self.logs: list[tuple[str, str]] = []
+ self.dispatcher = EventDispatcher(
+ log_warning=lambda msg, severity="info": self.logs.append((msg, severity))
+ )
+
+ def test_dispatcher_registers_dispatches_and_unregisters_handlers(self) -> None:
+ """Verify handlers receive events until they are unsubscribed."""
+ calls: list[str] = []
+ ready_event = ReadyEvent(celune=cast(Celune, SimpleNamespace()))
+
+ def first(_event: ReadyEvent) -> None:
+ calls.append("first")
+
+ def second(_event: ReadyEvent) -> None:
+ calls.append("second")
+
+ self.dispatcher.subscribe(
+ "ready",
+ cast(ReadyEventCallback, first),
+ owner_name="first",
+ )
+ self.dispatcher.subscribe(
+ "ready",
+ cast(ReadyEventCallback, second),
+ owner_name="second",
+ )
+ self.dispatcher.emit("ready", ready_event)
+ self.assertEqual(calls, ["first", "second"])
+
+ self.dispatcher.unsubscribe("ready", first)
+ self.dispatcher.emit("ready", ready_event)
+ self.assertEqual(calls, ["first", "second", "second"])
+
+ def test_dispatcher_logs_handler_failures_and_continues(self) -> None:
+ """Verify one failing handler does not block later handlers."""
+ calls: list[str] = []
+ ready_event = ReadyEvent(celune=cast(Celune, SimpleNamespace()))
+
+ def broken(_event: ReadyEvent) -> None:
+ calls.append("broken")
+ raise RuntimeError("boom")
+
+ def healthy(_event: ReadyEvent) -> None:
+ calls.append("healthy")
+
+ self.dispatcher.subscribe(
+ "ready",
+ cast(ReadyEventCallback, broken),
+ owner_name="broken",
+ )
+ self.dispatcher.subscribe(
+ "ready",
+ cast(ReadyEventCallback, healthy),
+ owner_name="healthy",
+ )
+
+ self.dispatcher.emit("ready", ready_event)
+
+ self.assertEqual(calls, ["broken", "healthy"])
+ self.assertEqual(self.logs[-1][1], "warning")
+ self.assertIn("Event callback failed", self.logs[-1][0])
+
+
+class ManagerEventTests(TestCase):
+ """Tests for extension-manager event integration."""
+
+ def setUp(self) -> None:
+ self.logs: list[tuple[str, str]] = []
+ self.dev_logs: list[tuple[str, str]] = []
+ self.context = CeluneContext(
+ log=lambda msg, severity="info": self.logs.append((msg, severity)),
+ log_dev=lambda msg, severity="info": self.dev_logs.append((msg, severity)),
+ say=lambda text, save=True, display_text=None: True,
+ think=lambda text: True,
+ play=lambda sound_path, keep=False, volume=1.0: True,
+ status=lambda msg, severity="info": None,
+ set_voice=lambda name: True,
+ get_state=lambda: "idle",
+ wait_until_ready=lambda timeout=30.0: True,
+ )
+ self.dispatcher = EventDispatcher(
+ log_warning=lambda msg, severity="info": self.logs.append((msg, severity))
+ )
+
+ def test_decorator_stores_subscription_metadata(self) -> None:
+ """Verify the decorator only records metadata until registration time."""
+
+ @subscribe("ready")
+ def on_ready(_event: ReadyEvent) -> None:
+ return None
+
+ subscriptions = iter_subscriptions(on_ready)
+ self.assertEqual(len(subscriptions), 1)
+ self.assertEqual(subscriptions[0].event_name, "ready")
+ self.assertEqual(subscriptions[0].enabled, True)
+
+ def test_manager_discovers_extension_handlers_and_unregisters_them(self) -> None:
+ """Verify decorated extension methods auto-register and clean up on unload."""
+ received: list[str] = []
+
+ class EventExtension(CeluneExtension):
+ """Fixture extension used by one event-manager test."""
+
+ EXTENSION_NAME = "Events"
+
+ @subscribe("ready")
+ def on_ready(self, _event: ReadyEvent) -> None:
+ """React to a mock ready event.
+
+ Args:
+ _event: The ready event payload.
+ """
+ received.append("ready")
+
+ def invoke(self, *args, **kwargs) -> None:
+ return None
+
+ manager = CeluneExtensionManager(self.context, self.dispatcher)
+ manager.register(EventExtension)
+ self.dispatcher.emit("ready", ReadyEvent(celune=mock.Mock(spec=Celune)))
+ self.assertEqual(received, ["ready"])
+
+ manager.unregister("Events")
+ self.dispatcher.emit("ready", ReadyEvent(celune=mock.Mock(spec=Celune)))
+ self.assertEqual(received, ["ready"])
+
+ def test_manager_can_disable_subscriptions_per_handler(self) -> None:
+ """Verify extensions can opt out of individual handlers."""
+ received: list[str] = []
+
+ class EventExtension(CeluneExtension):
+ """Fixture extension used by one ready-disable test."""
+
+ EXTENSION_NAME = "Events"
+
+ @subscribe("ready", enabled=False)
+ def on_ready(self, _event: ReadyEvent) -> None:
+ """React to a mock ready event.
+
+ Args:
+ _event: The ready event payload.
+ """
+ received.append("ready")
+
+ @subscribe("voice_changed", enabled=True)
+ def on_voice_changed(self, _event: VoiceChangedEvent) -> None:
+ """React to a mock voice change event.
+
+ Args:
+ _event: The voice-change event payload.
+ """
+ received.append("voice")
+
+ def invoke(self, *args, **kwargs) -> None:
+ return None
+
+ manager = CeluneExtensionManager(self.context, self.dispatcher)
+ manager.register(EventExtension)
+ self.dispatcher.emit("ready", ReadyEvent(celune=mock.Mock(spec=Celune)))
+ self.dispatcher.emit(
+ "voice_changed",
+ VoiceChangedEvent(
+ celune=mock.Mock(spec=Celune),
+ old_voice="balanced",
+ new_voice="bold",
+ ),
+ )
+ self.assertEqual(received, ["voice"])
+
+ def test_manager_autoloads_module_level_handlers(self) -> None:
+ """Verify event-only extension modules can subscribe through ``@celune.subscribe``."""
+ manager = CeluneExtensionManager(self.context, self.dispatcher)
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ extension_file = Path(temp_dir) / "fixture.py"
+ extension_file.write_text(
+ textwrap.dedent(
+ """
+ import celune
+
+ EVENTS = []
+
+ @celune.subscribe("ready")
+ def on_ready(event):
+ EVENTS.append(event.__class__.__name__)
+ """
+ ),
+ encoding="utf-8",
+ )
+
+ manager.autoload(temp_dir)
+ module = sys.modules["user_extension_fixture"]
+ self.dispatcher.emit("ready", ReadyEvent(celune=mock.Mock(spec=Celune)))
+
+ self.assertEqual(getattr(module, "EVENTS"), ["ReadyEvent"])
+
+
+class EngineEventIntegrationTests(TestCase):
+ """Tests for Celune runtime event emission."""
+
+ @staticmethod
+ def _close_celune(celune: Celune) -> None:
+ """Close a test instance if it still owns the singleton slot."""
+ if Celune._instance is celune:
+ celune.close()
+
+ def _make_celune(self) -> Celune:
+ """Create a lightweight Celune instance for event tests."""
+ with (
+ mock.patch("celune.celune.AudioRGBGlow", FakeGlow),
+ mock.patch("celune.celune.default_loader", return_value=None),
+ mock.patch("celune.celune.persona_is_available", return_value=False),
+ ):
+ celune = Celune(config={}, tts_backend=FakeBackend)
+ self.addCleanup(self._close_celune, celune)
+ return celune
+
+ def test_state_and_shutdown_events_are_emitted(self) -> None:
+ """Verify runtime state transitions and shutdown emit typed payloads."""
+ celune = self._make_celune()
+ state_events: list[StateChangedEvent] = []
+ shutdown_events: list[ShutdownEvent] = []
+ celune._event_dispatcher.subscribe("state_changed", state_events.append)
+ celune._event_dispatcher.subscribe("shutdown", shutdown_events.append)
+
+ celune.cur_state = "speaking"
+ celune.close()
+
+ self.assertEqual(state_events[-1].old_state, "init")
+ self.assertEqual(state_events[-1].new_state, "speaking")
+ self.assertEqual(len(shutdown_events), 1)
+
+ def test_voice_change_emits_typed_event(self) -> None:
+ """Verify successful voice changes emit a ``VoiceChangedEvent`` payload."""
+ celune = self._make_celune()
+ celune.current_voice = "balanced"
+ celune.voices = ("balanced", "bold")
+ celune.model_name = "shared-model"
+ celune.loaded = True
+ celune.cur_state = "idle"
+ celune.backend.model_id_for_voice = mock.Mock(return_value="shared-model")
+ events: list[VoiceChangedEvent] = []
+ celune._event_dispatcher.subscribe("voice_changed", events.append)
+
+ with mock.patch("celune.celune.play_signal", return_value=False):
+ celune.change_voice("bold")
+
+ self.assertEqual(len(events), 1)
+ self.assertEqual(events[0].old_voice, "balanced")
+ self.assertEqual(events[0].new_voice, "bold")
+
+ def test_character_bundle_events_emit_typed_payloads(self) -> None:
+ """Verify CEVOICE lifecycle transitions emit load, unload, and change events."""
+ celune = self._make_celune()
+ celune.backend.uses_voice_bundles = True
+ loaded_events: list[CharacterLoadedEvent] = []
+ unloaded_events: list[CharacterUnloadedEvent] = []
+ changed_events: list[CharacterChangedEvent] = []
+ celune._event_dispatcher.subscribe("character_loaded", loaded_events.append)
+ celune._event_dispatcher.subscribe("character_unloaded", unloaded_events.append)
+ celune._event_dispatcher.subscribe("character_changed", changed_events.append)
+
+ first_bundle = mock.Mock()
+ first_bundle.path = Path("celune.cevoice")
+ first_bundle.voice_order = ("balanced", "bold")
+ first_bundle.metadata = {"name": "Celune", "default_voice": "balanced"}
+ first_loader = mock.Mock(bundle=first_bundle)
+
+ second_bundle = mock.Mock()
+ second_bundle.path = Path("nova.cevoice")
+ second_bundle.voice_order = ("bold", "balanced")
+ second_bundle.metadata = {"name": "Nova", "default_voice": "bold"}
+ second_loader = mock.Mock(bundle=second_bundle)
+
+ with mock.patch("celune.celune.select_voice_bundle"):
+ with mock.patch(
+ "celune.celune.default_loader",
+ side_effect=[
+ None,
+ first_loader,
+ first_loader,
+ second_loader,
+ second_loader,
+ None,
+ ],
+ ):
+ self.assertEqual(celune.load_voice_bundle(Path("celune.cevoice")), True)
+ self.assertEqual(celune.load_voice_bundle(Path("nova.cevoice")), True)
+ self.assertEqual(celune.load_voice_bundle(None), True)
+
+ self.assertEqual(len(loaded_events), 1)
+ self.assertEqual(loaded_events[0].character_name, "Celune")
+ self.assertEqual(loaded_events[0].bundle_path, str(Path("celune.cevoice")))
+ self.assertEqual(len(changed_events), 1)
+ self.assertEqual(changed_events[0].old_character, "Celune")
+ self.assertEqual(changed_events[0].new_character, "Nova")
+ self.assertEqual(len(unloaded_events), 1)
+ self.assertEqual(unloaded_events[0].character_name, "Nova")
+
+ def test_typed_event_payloads_expose_expected_attributes(self) -> None:
+ """Verify runtime event payload dataclasses carry the documented fields."""
+ celune = self._make_celune()
+ audio_start = AudioStartEvent(
+ celune=celune,
+ source_id=1,
+ label="fixture",
+ kind="sfx",
+ )
+ audio_end = AudioEndEvent(
+ celune=celune,
+ source_id=1,
+ label="fixture",
+ kind="sfx",
+ saved_path="outputs/test.flac",
+ )
+
+ self.assertEqual(audio_start.label, "fixture")
+ self.assertEqual(audio_end.saved_path, "outputs/test.flac")
+ character_loaded = CharacterLoadedEvent(
+ celune=celune,
+ character_name="Celune",
+ bundle_path="celune.cevoice",
+ is_default=True,
+ )
+ self.assertEqual(character_loaded.character_name, "Celune")
diff --git a/tests/test_main_doctor.py b/tests/test_main_doctor.py
index 624960b..b25b1c3 100644
--- a/tests/test_main_doctor.py
+++ b/tests/test_main_doctor.py
@@ -14,6 +14,20 @@
class DoctorCommandTests(TestCase):
"""Verify `celune doctor` works without booting the full app."""
+ def test_ui_test_backend_is_loaded_lazily(self) -> None:
+ """Verify normal entrypoint imports do not require the test suite package."""
+ fake_support = mock.Mock(FakeBackend=object())
+
+ with mock.patch.object(
+ entrypoint.importlib,
+ "import_module",
+ return_value=fake_support,
+ ) as import_module:
+ backend = entrypoint._load_ui_test_backend()
+
+ self.assertIs(backend, fake_support.FakeBackend)
+ import_module.assert_called_once_with("tests.support")
+
def test_main_reports_unsupported_python_before_loading_entrypoint(self) -> None:
"""Verify doctor on Python 3.11 exits cleanly before importing 3.12-only modules."""
with (
diff --git a/tests/test_modeling.py b/tests/test_modeling.py
index 71d4be0..e257511 100644
--- a/tests/test_modeling.py
+++ b/tests/test_modeling.py
@@ -8,7 +8,7 @@
import torch
from celune import modeling
-from celune.backends import CeluneBackend
+from celune.backends.tts import CeluneBackend
class ModelingTests(TestCase):
diff --git a/tests/test_namedays_i18n_updater.py b/tests/test_namedays_i18n_updater.py
index e758610..16065d2 100644
--- a/tests/test_namedays_i18n_updater.py
+++ b/tests/test_namedays_i18n_updater.py
@@ -58,6 +58,21 @@ def test_string_falls_back_and_formats_values(self) -> None:
i18n.STRINGS.clear()
i18n.STRINGS.update(original)
+ def test_string_falls_back_from_specific_locale_to_base_language(self) -> None:
+ """Verify locale variants can reuse a base-language translation table.
+
+ Raises:
+ AssertionError: Locale normalization behavior changes unexpectedly.
+ """
+ original = dict(i18n.STRINGS)
+ try:
+ i18n.STRINGS["en"] = {"hello": "Hello"}
+ i18n.set_locale("en-US")
+ self.assertEqual(i18n.string("hello"), "Hello")
+ finally:
+ i18n.STRINGS.clear()
+ i18n.STRINGS.update(original)
+
class UpdaterTests(TestCase):
"""Tests for pure updater decision logic."""
diff --git a/tests/test_package_api.py b/tests/test_package_api.py
index 9241da1..a5def85 100644
--- a/tests/test_package_api.py
+++ b/tests/test_package_api.py
@@ -22,6 +22,7 @@ def test_dir_only_lists_curated_public_exports(self) -> None:
"__comment__",
"__tagline__",
"__version__",
+ "subscribe",
],
)
diff --git a/tests/test_persona_api.py b/tests/test_persona_api.py
index d40d4ff..e0f7434 100644
--- a/tests/test_persona_api.py
+++ b/tests/test_persona_api.py
@@ -6,6 +6,7 @@
from unittest import TestCase, mock
from typing import Optional, Union, cast
+from celune.constants import PERSONA_MODEL_REVISION
from celune.persona import impl
from celune.utils import discard
from celune.persona import runtime
@@ -324,9 +325,15 @@ def test_load_uses_causal_lm_for_qwen_vl_backend(self) -> None:
) as loaders:
backend.load("Qwen/Qwen3-VL-4B-Instruct", "none")
+ loaders["config_loader"].assert_called_once_with(
+ "Qwen/Qwen3-VL-4B-Instruct",
+ trust_remote_code=True,
+ revision=PERSONA_MODEL_REVISION,
+ )
loaders["processor_loader"].assert_called_once_with(
"Qwen/Qwen3-VL-4B-Instruct",
trust_remote_code=True,
+ revision=PERSONA_MODEL_REVISION,
)
loaders["model_loader"].assert_called_once()
self.assertEqual(
@@ -337,6 +344,7 @@ def test_load_uses_causal_lm_for_qwen_vl_backend(self) -> None:
loaders["model_loader"].call_args.kwargs,
{
"trust_remote_code": True,
+ "revision": PERSONA_MODEL_REVISION,
"device_map": "auto",
"torch_dtype": runtime.torch.bfloat16,
},
diff --git a/tests/test_persona_emotion.py b/tests/test_persona_emotion.py
new file mode 100644
index 0000000..ca8b45d
--- /dev/null
+++ b/tests/test_persona_emotion.py
@@ -0,0 +1,124 @@
+# SPDX-License-Identifier: MIT
+"""Tests for Persona emotion blending."""
+
+from unittest import TestCase
+from collections.abc import Sequence
+
+import numpy as np
+
+from celune.persona.emotion import (
+ EmotionAnalysis,
+ EmotionPrediction,
+ GOEMOTIONS_LABELS,
+ PersonaEmotionAnalyzer,
+)
+
+
+class StubPersonaEmotionAnalyzer(PersonaEmotionAnalyzer):
+ """Analyzer with deterministic embeddings and labels for emotion tests."""
+
+ def __init__(
+ self,
+ analyses_by_text: dict[str, EmotionAnalysis],
+ prototypes: dict[str, np.ndarray],
+ ) -> None:
+ super().__init__(model_name="stub")
+ self._analyses_by_text = analyses_by_text
+ self._prototypes = {
+ label: vector.astype(np.float32) for label, vector in prototypes.items()
+ }
+
+ def analyze_texts(self, texts: Sequence[str]):
+ return [self._analyses_by_text[text] for text in texts]
+
+ def _prototype_embeddings(self):
+ return self._prototypes
+
+
+class PersonaEmotionTests(TestCase):
+ """Verify weighted Persona emotion blending stays stable."""
+
+ @staticmethod
+ def _analysis(
+ embedding: tuple[float, float],
+ top_label: str,
+ other_label: str,
+ ) -> EmotionAnalysis:
+ """Build one simple deterministic analysis object."""
+ return EmotionAnalysis(
+ embedding=np.array(embedding, dtype=np.float32),
+ predictions=(
+ EmotionPrediction(label=top_label, score=0.9),
+ EmotionPrediction(label=other_label, score=0.1),
+ ),
+ )
+
+ def test_weighted_history_prefers_user_emotion_and_softens_negative_target(
+ self,
+ ) -> None:
+ """Verify user-heavy blending turns negative emotion into soft reinforcement."""
+ analyzer = StubPersonaEmotionAnalyzer(
+ analyses_by_text={
+ "Earlier I was okay.": self._analysis((1.0, 0.0), "sadness", "joy"),
+ "I am still here with you.": self._analysis(
+ (0.0, 1.0), "joy", "sadness"
+ ),
+ "I feel devastated.": self._analysis((1.0, 0.0), "sadness", "joy"),
+ },
+ prototypes={
+ "sadness": np.array((1.0, 0.0), dtype=np.float32),
+ "joy": np.array((0.0, 1.0), dtype=np.float32),
+ },
+ )
+
+ state = analyzer.summarize_history(
+ history=(
+ {"role": "user", "content": "Earlier I was okay."},
+ {"role": "assistant", "content": "I am still here with you."},
+ ),
+ request="I feel devastated.",
+ )
+
+ assert state is not None
+ self.assertEqual(state.target_label, "sadness")
+ self.assertIn("gently reassuring", state.target_state)
+ self.assertEqual(state.user_label, "sadness")
+
+ def test_positive_target_is_mirrored_softly(self) -> None:
+ """Verify positive blends preserve the feeling but soften the delivery."""
+ analyzer = StubPersonaEmotionAnalyzer(
+ analyses_by_text={
+ "This is amazing!": self._analysis((0.0, 1.0), "joy", "sadness"),
+ "I'm glad you like it.": self._analysis((0.0, 1.0), "joy", "sadness"),
+ },
+ prototypes={
+ "sadness": np.array((1.0, 0.0), dtype=np.float32),
+ "joy": np.array((0.0, 1.0), dtype=np.float32),
+ },
+ )
+
+ state = analyzer.summarize_history(
+ history=({"role": "assistant", "content": "I'm glad you like it."},),
+ request="This is amazing!",
+ )
+
+ assert state is not None
+ self.assertEqual(state.target_label, "joy")
+ self.assertIn("softly joyful", state.target_state)
+
+ def test_generic_huggingface_labels_remap_to_goemotions_by_index(self) -> None:
+ """Verify generic LABEL_n configs do not leak placeholder names into Persona state."""
+ config = type(
+ "Config",
+ (),
+ {
+ "id2label": {
+ str(index): f"LABEL_{index}"
+ for index in range(len(GOEMOTIONS_LABELS))
+ }
+ },
+ )()
+
+ labels = PersonaEmotionAnalyzer._resolve_labels(config)
+
+ self.assertEqual(labels, GOEMOTIONS_LABELS)
diff --git a/tests/test_persona_memory.py b/tests/test_persona_memory.py
index 72cd186..fbb3e4f 100644
--- a/tests/test_persona_memory.py
+++ b/tests/test_persona_memory.py
@@ -3,7 +3,7 @@
import tempfile
from pathlib import Path
-from unittest import TestCase
+from unittest import TestCase, mock
from typing import Union, Optional
from collections.abc import Sequence
@@ -192,6 +192,26 @@ def test_fallback_retrieval_still_works_when_embeddings_are_unavailable(
"my project is the lighthouse refactor",
)
+ def test_fallback_retrieval_survives_missing_offline_embedding_cache(
+ self,
+ ) -> None:
+ """Verify Hugging Face offline cache misses fall back to token overlap."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ store = PersonaMemoryStore(storage_dir=temp_dir)
+ store.remember("Celune", "my project is the lighthouse refactor")
+
+ with mock.patch(
+ "celune.persona.memory.AutoTokenizer.from_pretrained",
+ side_effect=OSError("offline cache missing"),
+ ):
+ retrieved = store.retrieve("Celune", "tell me about my project")
+
+ self.assertEqual(len(retrieved), 1)
+ self.assertEqual(
+ retrieved[0].content,
+ "my project is the lighthouse refactor",
+ )
+
def test_semantic_similarity_threshold_controls_retrieval(self) -> None:
"""Verify the configured semantic threshold gates borderline matches."""
with (
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index 513c64e..b8ef317 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -19,11 +19,12 @@
from celune import pipeline
from celune.celune import Celune
+from celune.dataclasses.pipeline import AudioInputRequest
from celune.utils import discard
from celune.persona.prompts import PersonaPromptBuilder
from celune.constants import JSON, JSONSerializable, PipelineStates
from celune.cevoice import CEVoicePersona, PersonaIdentity, PersonaStyleValues
-from .support import FakeStream, make_pipeline_engine
+from .support import FakeStream, FakeVCBackend, make_pipeline_engine, make_voice_loader
from .test_persona_memory import StubEmbeddingMemoryStore
@@ -128,6 +129,65 @@ def test_queue_helpers_and_force_stop_cover_busy_and_idle_paths(self) -> None:
self.assertEqual(engine.text_queue.empty(), True)
self.assertIs(engine.audio_queue.get_nowait(), engine.force_stop_marker)
+ def test_working_signal_completion_does_not_notify_idle(self) -> None:
+ """Verify the transitional working cue is not treated as a readiness idle event."""
+ engine = make_pipeline_engine()
+ engine.cur_state = "reloading"
+
+ self.assertEqual(pipeline.play_signal(cast(Celune, engine), "working"), True)
+
+ queued = list(engine.audio_queue.queue)
+ done_markers = [
+ item for item in queued if isinstance(item, pipeline.PlaybackSourceDone)
+ ]
+ self.assertEqual(len(done_markers), 1)
+ self.assertEqual(done_markers[0].notify_idle, False)
+ self.assertEqual(engine.cur_state, "reloading")
+
+ def test_readiness_signal_does_not_block_concurrent_speech_queueing(self) -> None:
+ """Verify the readiness cue does not briefly reject speech as busy."""
+ engine = make_pipeline_engine()
+ queued_during_signal: list[bool] = []
+ original_register = pipeline.register_playback_source
+
+ def register_and_queue(
+ engine_arg: Celune,
+ source_id: int,
+ *,
+ kind: str,
+ base_gain: float = 1.0,
+ ) -> None:
+ with mock.patch(
+ "celune.pipeline.detect_language",
+ return_value={
+ "language": "en",
+ "languages": ["en"],
+ "supported": True,
+ "probabilities": {"en": 1.0},
+ },
+ ):
+ queued_during_signal.append(
+ pipeline.queue_speech(cast(Celune, engine), "hello")
+ )
+ original_register(
+ engine_arg,
+ source_id,
+ kind=kind,
+ base_gain=base_gain,
+ )
+
+ with mock.patch(
+ "celune.pipeline._register_playback_source",
+ side_effect=register_and_queue,
+ ):
+ self.assertEqual(
+ pipeline.play_signal(cast(Celune, engine), "readiness"), True
+ )
+
+ self.assertEqual(queued_during_signal, [True])
+ request = engine.text_queue.get_nowait()
+ self.assertEqual(request.text, "hello")
+
def test_queue_speech_handles_success_and_failure_paths(self) -> None:
"""Verify speech queueing success and rejection paths.
@@ -214,6 +274,181 @@ def test_queue_speech_handles_success_and_failure_paths(self) -> None:
self.assertEqual(pipeline.queue_speech(cast(Celune, engine), "hello"), False)
self.assertEqual(engine.errors, ["Celune is not currently ready"])
+ def test_handle_audio_input_accepts_and_ignores_audio_by_default(self) -> None:
+ """Verify engine-level audio input is a safe explicit no-op in TTS mode."""
+ engine = make_pipeline_engine()
+ engine.log = mock.Mock()
+ engine.log_dev = mock.Mock()
+ engine.loaded = True
+ engine.locked = False
+ engine.cur_state = "idle"
+ audio = np.ones((16, 2), dtype=np.float32)
+ request = AudioInputRequest(audio=audio, sample_rate=48000, label="mic test")
+
+ result = pipeline.handle_audio_input(cast(Celune, engine), request)
+
+ self.assertEqual(result, True)
+ self.assertEqual(engine.text_queue.empty(), True)
+ self.assertEqual(engine.audio_queue.empty(), True)
+ self.assertEqual(engine.cur_state, "idle")
+ engine.log.assert_not_called()
+ engine.log_dev.assert_called_once()
+
+ def test_handle_audio_input_routes_to_vc_backend_in_voice_conversion_mode(
+ self,
+ ) -> None:
+ """Verify VC mode sends audio input through the configured VC backend."""
+ engine = make_pipeline_engine()
+ engine.input_mode = "voice_conversion"
+ engine.vc_backend = FakeVCBackend(log=lambda _msg, _severity="info": None)
+ engine.current_voice = "balanced"
+ engine.current_character = "Celune"
+ audio = np.ones((16, 2), dtype=np.float32)
+ request = AudioInputRequest(audio=audio, sample_rate=48000, label="mic test")
+
+ convert_mock = mock.Mock(
+ return_value=SimpleNamespace(
+ audio=np.asarray(audio, dtype=np.float32).copy(),
+ sample_rate=48000,
+ label="mic test",
+ )
+ )
+ engine.vc_backend.convert = convert_mock
+ loader = make_voice_loader("balanced", {"reference_text": "Pack reference."})
+
+ with (
+ mock.patch("celune.pipeline.default_loader", return_value=loader),
+ mock.patch("celune.pipeline.queue_sfx_audio", return_value=True) as queue,
+ ):
+ result = pipeline.handle_audio_input(cast(Celune, engine), request)
+
+ self.assertEqual(result, True)
+ convert_mock.assert_called_once()
+ vc_request = convert_mock.call_args.args[0]
+ self.assertEqual(vc_request.target_references, (Path("balanced.wav"),))
+ self.assertEqual(vc_request.pitch_shift, 0)
+ self.assertEqual(vc_request.f0_condition, False)
+ queue.assert_called_once()
+ queued_audio = queue.call_args.args[1]
+ self.assertEqual(queue.call_args.args[2], 48000)
+ self.assertEqual(queue.call_args.args[3], "mic test")
+ self.assertEqual(queued_audio.shape, (16, 2))
+ self.assertIsNot(queued_audio, audio)
+ self.assertEqual(np.array_equal(queued_audio, audio), True)
+ self.assertEqual(engine.text_queue.empty(), True)
+
+ def test_handle_audio_input_reports_missing_vc_backend_cleanly(self) -> None:
+ """Verify VC mode surfaces a clean error when no VC backend is configured."""
+ engine = make_pipeline_engine()
+ engine.input_mode = "voice_conversion"
+ engine.vc_backend = None
+ engine.log = mock.Mock()
+ audio = np.ones((8, 2), dtype=np.float32)
+
+ result = pipeline.handle_audio_input(
+ cast(Celune, engine),
+ AudioInputRequest(audio=audio, sample_rate=24000, label="fixture"),
+ )
+
+ self.assertEqual(result, False)
+ engine.log.assert_called_once()
+ self.assertEqual(
+ engine.errors,
+ ["Voice conversion backend is not configured."],
+ )
+ self.assertEqual(engine.audio_queue.empty(), True)
+
+ def test_handle_audio_input_applies_engine_vc_pitch_shift_to_output(
+ self,
+ ) -> None:
+ """Verify VC routing applies the configured pitch shift to converted output."""
+ engine = make_pipeline_engine()
+ engine.input_mode = "voice_conversion"
+ engine.vc_backend = FakeVCBackend(log=lambda _msg, _severity="info": None)
+ engine.current_voice = "balanced"
+ engine.current_character = "Celune"
+ engine.vc_pitch_shift = -5
+ request = AudioInputRequest(
+ audio=np.ones((12, 2), dtype=np.float32),
+ sample_rate=48000,
+ label="mic test",
+ )
+ convert_mock = mock.Mock(
+ return_value=SimpleNamespace(
+ audio=np.ones((12, 2), dtype=np.float32),
+ sample_rate=48000,
+ label="mic test",
+ )
+ )
+ engine.vc_backend.convert = convert_mock
+ loader = make_voice_loader("balanced", {"reference_text": "Pack reference."})
+
+ with (
+ mock.patch("celune.pipeline.default_loader", return_value=loader),
+ mock.patch("celune.pipeline.queue_sfx_audio", return_value=True),
+ mock.patch(
+ "celune.pipeline.pitch_shift_audio",
+ return_value=np.ones((12, 2), dtype=np.float32) * 0.25,
+ ) as shift_audio,
+ ):
+ result = pipeline.handle_audio_input(cast(Celune, engine), request)
+
+ self.assertEqual(result, True)
+ self.assertEqual(convert_mock.call_args.args[0].pitch_shift, 0)
+ shift_audio.assert_called_once_with(mock.ANY, 48000, -5)
+
+ def test_handle_audio_input_passes_engine_vc_f0_condition_to_vc_backend(
+ self,
+ ) -> None:
+ """Verify VC routing carries the configured engine conversion mode."""
+ engine = make_pipeline_engine()
+ engine.input_mode = "voice_conversion"
+ engine.vc_backend = FakeVCBackend(log=lambda _msg, _severity="info": None)
+ engine.current_voice = "balanced"
+ engine.current_character = "Celune"
+ engine.vc_f0_condition = True
+ request = AudioInputRequest(
+ audio=np.ones((12, 2), dtype=np.float32),
+ sample_rate=48000,
+ label="mic test",
+ )
+ convert_mock = mock.Mock(
+ return_value=SimpleNamespace(
+ audio=np.ones((12, 2), dtype=np.float32),
+ sample_rate=48000,
+ label="mic test",
+ )
+ )
+ engine.vc_backend.convert = convert_mock
+ loader = make_voice_loader("balanced", {"reference_text": "Pack reference."})
+
+ with (
+ mock.patch("celune.pipeline.default_loader", return_value=loader),
+ mock.patch("celune.pipeline.queue_sfx_audio", return_value=True),
+ ):
+ result = pipeline.handle_audio_input(cast(Celune, engine), request)
+
+ self.assertEqual(result, True)
+ self.assertEqual(convert_mock.call_args.args[0].f0_condition, True)
+
+ def test_tts_mode_does_not_route_audio_to_vc_backend(self) -> None:
+ """Verify the default TTS mode ignores audio instead of invoking VC routing."""
+ engine = make_pipeline_engine()
+ engine.input_mode = "text_to_speech"
+ engine.vc_backend = mock.Mock()
+
+ result = pipeline.handle_audio_input(
+ cast(Celune, engine),
+ AudioInputRequest(
+ audio=np.ones((4, 2), dtype=np.float32),
+ sample_rate=16000,
+ label="fixture",
+ ),
+ )
+
+ self.assertEqual(result, True)
+ engine.vc_backend.convert.assert_not_called()
+
def test_download_youtube_sfx_writes_expected_temp_wav(self) -> None:
"""Verify yt-dlp downloads to Celune's fixed temporary WAV path."""
engine = make_pipeline_engine()
@@ -514,13 +749,136 @@ def test_playback_worker_mixes_sources_and_glow_receives_mixed_audio(self) -> No
pipeline.playback_worker(cast(Celune, engine))
self.assertEqual(fake_stream.started, True)
- self.assertGreater(len(fake_stream.written), 1)
+ self.assertEqual(len(fake_stream.written), 1)
mixed_audio = np.concatenate(fake_stream.written)
+ self.assertEqual(mixed_audio.shape, (2400, 2))
np.testing.assert_allclose(mixed_audio, 0.5, atol=1e-6)
self.assertEqual(len(glow_calls), len(fake_stream.written))
np.testing.assert_allclose(np.concatenate(glow_calls), 0.5, atol=1e-6)
self.assertEqual(engine.playback_done.is_set(), True)
+ def test_playback_worker_uses_configured_output_device(self) -> None:
+ """Verify playback streams honor the configured output device override."""
+ engine = make_pipeline_engine()
+ engine.config = {"output_recording_device": "VB-Cable Output"}
+ engine.stream = None
+ engine._stream = None
+ engine._current_sr = None
+ engine.current_sr = None
+ engine.dev = False
+ engine.current_voice = "balanced"
+ engine.idle_callback = mock.Mock()
+ engine.glow = SimpleNamespace(schedule=mock.Mock())
+ engine.text_queue = queue.Queue()
+ engine.audio_queue = queue.Queue()
+ engine.sentinel = PipelineStates.TERMINATE
+ engine.force_stop_marker = PipelineStates.UTTERANCE_FORCE_END
+ fake_stream = FakeStream()
+
+ pipeline.queue_playback_chunk(
+ cast(Celune, engine),
+ 1,
+ np.full((2400, 2), 0.2, dtype=np.float32),
+ 48000,
+ )
+ pipeline.queue_playback_done(cast(Celune, engine), 1)
+ engine.audio_queue.put(engine.sentinel)
+
+ with mock.patch(
+ "celune.pipeline.sd.OutputStream",
+ return_value=fake_stream,
+ ) as mock_stream:
+ pipeline.playback_worker(cast(Celune, engine))
+
+ self.assertEqual(mock_stream.call_args.kwargs["device"], "VB-Cable Output")
+
+ def test_playback_worker_logs_friendly_output_device_match_errors(self) -> None:
+ """Verify ambiguous output devices are logged without surfacing a traceback."""
+ engine = make_pipeline_engine()
+ engine.config = {"output_recording_device": "CABLE-B Input"}
+ engine.stream = None
+ engine._stream = None
+ engine._current_sr = None
+ engine.current_sr = None
+ engine.dev = False
+ engine.current_voice = "balanced"
+ engine.idle_callback = mock.Mock()
+ engine.glow = SimpleNamespace(schedule=mock.Mock())
+ engine.text_queue = queue.Queue()
+ engine.audio_queue = queue.Queue()
+ engine.sentinel = PipelineStates.TERMINATE
+ engine.force_stop_marker = PipelineStates.UTTERANCE_FORCE_END
+
+ pipeline.queue_playback_chunk(
+ cast(Celune, engine),
+ 1,
+ np.full((2400, 2), 0.2, dtype=np.float32),
+ 48000,
+ )
+ pipeline.queue_playback_done(cast(Celune, engine), 1)
+ engine.audio_queue.put(engine.sentinel)
+
+ with mock.patch(
+ "celune.pipeline.resolve_audio_device",
+ side_effect=ValueError(
+ "The specified output device name has multiple matches for "
+ "'CABLE-B Input (VB-Audio Cable B)':\n"
+ "- [22] CABLE-B Input (VB-Audio Cable B), Windows DirectSound\n"
+ "- [28] CABLE-B Input (VB-Audio Cable B), Windows WASAPI\n\n"
+ "Please specify one of the above devices, then restart Celune."
+ ),
+ ):
+ pipeline.playback_worker(cast(Celune, engine))
+
+ self.assertEqual(engine.errors[-1], "No suitable audio devices")
+ error_messages = [
+ msg for msg, severity in engine.messages if severity == "error"
+ ]
+ self.assertTrue(error_messages)
+ self.assertIn(
+ "The specified output device name has multiple matches",
+ error_messages[-1],
+ )
+
+ def test_playback_worker_does_not_emit_idle_for_non_idle_completion_marker(
+ self,
+ ) -> None:
+ """Verify non-readiness completion markers cannot snap the runtime back to idle."""
+ engine = make_pipeline_engine()
+ engine.stream = None
+ engine._stream = None
+ engine._current_sr = None
+ engine.current_sr = None
+ engine.dev = False
+ engine.cur_state = "reloading"
+ engine.idle_callback = mock.Mock()
+ engine.glow = SimpleNamespace(schedule=mock.Mock())
+ engine.text_queue = queue.Queue()
+ engine.audio_queue = queue.Queue()
+ engine.sentinel = PipelineStates.TERMINATE
+ engine.force_stop_marker = PipelineStates.UTTERANCE_FORCE_END
+ fake_stream = FakeStream()
+
+ pipeline.queue_playback_chunk(
+ cast(Celune, engine),
+ 1,
+ np.full((2400, 2), 0.2, dtype=np.float32),
+ 48000,
+ )
+ pipeline.queue_playback_done(
+ cast(Celune, engine),
+ 1,
+ notify_idle_when_finished=False,
+ )
+ engine.audio_queue.put(engine.sentinel)
+
+ with mock.patch("celune.pipeline.sd.OutputStream", return_value=fake_stream):
+ pipeline.playback_worker(cast(Celune, engine))
+
+ engine.idle_callback.assert_not_called()
+ self.assertEqual(engine.cur_state, "reloading")
+ self.assertEqual(engine.playback_done.is_set(), True)
+
def test_playback_worker_reports_live_audio_progress(self) -> None:
"""Verify playback progress follows audio position without flooding updates."""
engine = make_pipeline_engine()
@@ -808,6 +1166,36 @@ def test_finalize_playback_idle_resets_glow_audio_reactivity(self) -> None:
self.assertEqual(engine.cur_state, "idle")
engine.idle_callback.assert_called_once_with()
+ def test_finalize_playback_idle_does_not_announce_readiness_while_reloading(
+ self,
+ ) -> None:
+ """Verify transitional playback does not announce readiness mid-reload."""
+ engine = make_pipeline_engine()
+ engine.locked = True
+ engine.loaded = False
+ engine.cur_state = "reloading"
+
+ pipeline.finalize_playback_idle(cast(Celune, engine))
+
+ self.assertNotIn(("Ready to speak.", "info"), engine.messages)
+ self.assertEqual(getattr(engine, "_ready_announced", False), False)
+ self.assertEqual(engine.cur_state, "reloading")
+
+ def test_finalize_playback_idle_does_not_emit_idle_callback_while_locked(
+ self,
+ ) -> None:
+ """Verify locked non-readiness playback cannot unlock the UI through idle callbacks."""
+ engine = make_pipeline_engine()
+ engine.locked = True
+ engine.loaded = False
+ engine.cur_state = "reloading"
+
+ pipeline.finalize_playback_idle(cast(Celune, engine))
+
+ engine.idle_callback.assert_not_called()
+ self.assertEqual(engine.playback_done.is_set(), True)
+ self.assertEqual(engine.cur_state, "reloading")
+
def test_think_builds_persona_payload_and_queues_response(self) -> None:
"""Verify Persona request formatting without loading a Persona model.
@@ -919,23 +1307,18 @@ def post(self, json: JSON) -> FakeResponse:
self.assertIn("Soft-spoken, intimate, and reflective", character_card)
self.assertIn("Prompt Rules:", character_card)
self.assertIn("Example Dialogue:", character_card)
- self.assertIn("", system_prompt)
- self.assertIn("", system_prompt)
- self.assertIn("", system_prompt)
- self.assertIn("", system_prompt)
- self.assertIn("Read the conversation in ", system_prompt)
+ self.assertIn("", system_prompt)
+ self.assertIn("", system_prompt)
+ self.assertIn("", system_prompt)
self.assertIn("Earlier reply.", system_prompt)
self.assertIn("user: What now?", system_prompt)
self.assertIn("The assistant has already acknowledged", system_prompt)
+ self.assertIn("You are Celune", system_prompt)
+ self.assertIn("refer to yourself as Celune", system_prompt)
self.assertIn("Celune:", system_prompt)
- self.assertNotIn("", system_prompt)
- self.assertNotIn("", system_prompt)
self.assertEqual(messages[0], {"role": "system", "content": system_prompt})
self.assertEqual(messages[-1], {"role": "user", "content": "What now?"})
self.assertEqual(len(messages), 2)
- self.assertIn("small pauses", messages[0]["content"])
- self.assertNotIn("User Request", character_card)
- self.assertNotIn("Assistant Response", character_card)
self.assertEqual(
engine.persona_history[-2:],
[
@@ -1010,7 +1393,6 @@ def test_persona_prompt_builder_renders_structured_context_blocks(self) -> None:
engine = make_pipeline_engine()
engine.config = {
"persona_character_profile": "A careful archivist with a dry wit.",
- "persona_relationship_memory": "The user trusts the character with private notes.",
"persona_state": "Thoughtful and slightly tired.",
"persona_long_term_memory": [
"The user prefers concise answers.",
@@ -1037,59 +1419,47 @@ def test_persona_prompt_builder_renders_structured_context_blocks(self) -> None:
)
prompt = PersonaPromptBuilder.build(context)
- self.assertIn("", prompt)
- self.assertIn("", prompt)
- self.assertIn("Name: Fixture", prompt)
- self.assertIn("A careful archivist with a dry wit.", prompt)
- self.assertIn("", prompt)
- self.assertIn("The user trusts the character with private notes.", prompt)
- self.assertIn("", prompt)
+ self.assertIn("", prompt)
+ self.assertIn("", prompt)
+ self.assertIn("- The user prefers concise answers.", prompt)
+ self.assertIn(
+ "- The character once helped recover a lost journal.",
+ prompt,
+ )
+ self.assertIn("", prompt)
self.assertIn("Thoughtful and slightly tired.", prompt)
- self.assertIn("", prompt)
- self.assertIn("The user prefers concise answers.", prompt)
- self.assertIn("", prompt)
+ self.assertIn("", prompt)
self.assertIn("assistant: Yes, we catalogued the letters.", prompt)
self.assertIn("user: What do you notice?", prompt)
+ self.assertIn("You are Fixture", prompt)
+ self.assertIn(
+ "Push the conversation forward instead of returning to earlier turns.",
+ prompt,
+ )
+ self.assertIn(
+ "Treat facts in as true context when they are relevant.",
+ prompt,
+ )
+ self.assertIn(
+ "Keep items from silent unless the current user message clearly asks for them",
+ prompt,
+ )
self.assertIn(
"The assistant has already acknowledged",
prompt,
)
- self.assertIn("", prompt)
- self.assertIn("image: archive.png", prompt)
self.assertIn(
"Do not greet the user. Do not ask what they need. Just respond.",
prompt,
)
+ self.assertIn(
+ "Do not bring up older messages, stored facts, or resolved topics on your own.",
+ prompt,
+ )
self.assertIn("Fixture:", prompt)
self.assertIn("What do you notice?", prompt)
self.assertNotIn("", prompt)
- def test_persona_context_retrieves_persisted_long_term_memory(self) -> None:
- """Verify Persona prompts pull relevant persisted memory for the character."""
- engine = make_pipeline_engine()
- with tempfile.TemporaryDirectory() as temp_dir:
- engine.config = {
- "persona": {"memory": {"storage_dir": temp_dir}},
- }
- engine.current_character = "Fixture"
- engine.current_voice = "balanced"
- store = StubEmbeddingMemoryStore(storage_dir=temp_dir)
- store.return_none = True
- engine.persona_memory_store = store
- store.remember(
- "Fixture",
- "my test word is moonlight",
- explicit=True,
- )
-
- context = pipeline.build_persona_context(
- cast(Celune, engine), "what is my test word?"
- )
- prompt = PersonaPromptBuilder.build(context)
-
- self.assertIn("my test word is moonlight", prompt)
- self.assertIn("", prompt)
-
def test_cevoice_persona_metadata_populates_persona_card(self) -> None:
"""Verify CEVOICE persona metadata becomes the active Persona card."""
engine = make_pipeline_engine()
@@ -1133,6 +1503,15 @@ def test_cevoice_persona_metadata_populates_persona_card(self) -> None:
"A precise investigator who notices tiny shifts in tone.",
context.character_profile.render(),
)
+ self.assertEqual(
+ context.character_profile.render_identity_summary(),
+ "\n".join(
+ (
+ "You are Mirelle, a precise investigator who notices tiny shifts in tone.",
+ "When asked for an introduction, refer to yourself as Mirelle.",
+ )
+ ),
+ )
self.assertIn("Style Notes:", card)
self.assertIn("Elegant, steady, and mildly teasing.", card)
self.assertIn("Boundaries:", card)
@@ -1140,6 +1519,13 @@ def test_cevoice_persona_metadata_populates_persona_card(self) -> None:
self.assertIn("Example Dialogue:", card)
self.assertIn("- Formality: high", card)
self.assertIn("- Enthusiasm: low", card)
+ self.assertEqual(
+ context.persona_card.behavior_cues(),
+ (
+ "Elegant, steady, and mildly teasing.",
+ "Do not use sterile assistant framing.\n- Do not sound detached.",
+ ),
+ )
def test_different_cevoice_personas_produce_distinct_prompts(self) -> None:
"""Verify different CEVOICE persona packs shape different Persona prompts."""
@@ -1171,10 +1557,8 @@ def test_different_cevoice_personas_produce_distinct_prompts(self) -> None:
)
self.assertNotEqual(first_prompt, second_prompt)
- self.assertIn("A precise investigator.", first_prompt)
- self.assertIn("A mischievous mechanic.", second_prompt)
- self.assertIn("Elegant and steady.", first_prompt)
- self.assertIn("Fast, playful, and sharp.", second_prompt)
+ self.assertIn("Mirelle:", first_prompt)
+ self.assertIn("Rho:", second_prompt)
def test_persona_prompt_does_not_hardcode_celune_identity(self) -> None:
"""Verify Persona prompts stay character-agnostic without pack metadata."""
@@ -1187,8 +1571,9 @@ def test_persona_prompt_does_not_hardcode_celune_identity(self) -> None:
pipeline.build_persona_context(cast(Celune, engine), "Hello.")
)
- self.assertIn("Name: Fixture", prompt)
+ self.assertIn("Fixture:", prompt)
self.assertNotIn("Name: Celune", prompt)
+ self.assertIn("You are Fixture", prompt)
def test_default_celune_prompt_uses_canonical_age_and_gender(self) -> None:
"""Verify default Celune prompts expose the intended identity fields."""
@@ -1202,9 +1587,107 @@ def test_default_celune_prompt_uses_canonical_age_and_gender(self) -> None:
pipeline.build_persona_context(cast(Celune, engine), "Hello.")
)
- self.assertIn("Name: Celune", prompt)
- self.assertIn("Age: 28", prompt)
- self.assertIn("Gender: female", prompt)
+ self.assertIn("Celune:", prompt)
+ self.assertIn("You are Celune", prompt)
+ self.assertNotIn("Gender: female", prompt)
+ self.assertNotIn("The speaker uses a more confident", prompt)
+
+ def test_named_celune_custom_pack_does_not_use_default_identity(self) -> None:
+ """Verify custom packs named Celune do not inherit default identity fields."""
+ engine = make_pipeline_engine()
+ engine.config = {}
+ engine.current_character = "Celune"
+ engine.current_voice = "balanced"
+ engine.voice_bundle_is_default = False
+
+ prompt = PersonaPromptBuilder.build(
+ pipeline.build_persona_context(cast(Celune, engine), "Hello.")
+ )
+
+ self.assertIn("Celune:", prompt)
+ self.assertIn("You are Celune", prompt)
+
+ def test_persona_context_uses_weighted_emotion_state_when_unconfigured(
+ self,
+ ) -> None:
+ """Verify Persona state can come from weighted conversation emotion."""
+ engine = make_pipeline_engine()
+ engine.config = {}
+ engine.current_character = "Fixture"
+ engine.current_voice = "balanced"
+ engine.persona_history = [
+ {"role": "user", "content": "I feel awful."},
+ {"role": "assistant", "content": "I am staying steady."},
+ ]
+
+ fake_analyzer = SimpleNamespace(
+ summarize_history=mock.Mock(
+ return_value=SimpleNamespace(
+ target_state=(
+ "Target emotion: gently reassuring. "
+ "The user's recent mood leans toward sadness."
+ )
+ )
+ )
+ )
+
+ with mock.patch(
+ "celune.pipeline._persona_emotion_analyzer",
+ return_value=fake_analyzer,
+ ):
+ context = pipeline.build_persona_context(
+ cast(Celune, engine), "Please stay with me."
+ )
+
+ self.assertIn("Target emotion: gently reassuring.", context.mood_or_state)
+ fake_analyzer.summarize_history.assert_called_once()
+
+ def test_persona_context_prefers_configured_state_over_emotion_analysis(
+ self,
+ ) -> None:
+ """Verify an explicit persona_state still overrides automatic emotion blending."""
+ engine = make_pipeline_engine()
+ engine.config = {"persona_state": "Thoughtful and slightly tired."}
+ engine.current_character = "Fixture"
+ engine.current_voice = "balanced"
+
+ with mock.patch("celune.pipeline._persona_emotion_analyzer") as analyzer:
+ context = pipeline.build_persona_context(cast(Celune, engine), "Hello.")
+
+ self.assertEqual(context.mood_or_state, "Thoughtful and slightly tired.")
+ analyzer.assert_not_called()
+
+ def test_persona_context_logs_emotion_fallback_reason(self) -> None:
+ """Verify emotion-analysis failures are surfaced in developer logs."""
+ engine = make_pipeline_engine()
+ engine.config = {}
+ engine.current_character = "Fixture"
+ engine.current_voice = "balanced"
+ captured: list[tuple[str, str]] = []
+ engine.log_dev = lambda msg, severity="info": captured.append((msg, severity))
+
+ fake_analyzer = SimpleNamespace(
+ last_error="lunahr/emotispace-128 could not be loaded",
+ summarize_history=mock.Mock(return_value=None),
+ )
+
+ with mock.patch(
+ "celune.pipeline._persona_emotion_analyzer",
+ return_value=fake_analyzer,
+ ):
+ context = pipeline.build_persona_context(cast(Celune, engine), "Hello.")
+
+ self.assertEqual(context.mood_or_state, "Neutral.")
+ self.assertEqual(
+ captured,
+ [
+ (
+ "Persona emotion analysis fell back to Neutral: "
+ "lunahr/emotispace-128 could not be loaded",
+ "warning",
+ )
+ ],
+ )
def test_persona_prompt_builder_omits_vision_context_without_attachments(
self,
@@ -1223,7 +1706,7 @@ def test_persona_prompt_builder_omits_vision_context_without_attachments(
prompt = PersonaPromptBuilder.build(context)
self.assertNotIn("", prompt)
- self.assertIn("", prompt)
+ self.assertIn("", prompt)
self.assertIn("assistant: hi", prompt)
def test_persona_messages_keep_only_recent_history(self) -> None:
@@ -1245,7 +1728,7 @@ def test_persona_messages_keep_only_recent_history(self) -> None:
self.assertEqual(messages[-1], {"role": "user", "content": "current"})
self.assertEqual(len(messages), 2)
system_prompt = cast(str, messages[0]["content"])
- self.assertIn("", system_prompt)
+ self.assertIn("", system_prompt)
self.assertIn("user: old user 6", system_prompt)
self.assertIn("assistant: old reply 11", system_prompt)
self.assertNotIn("old user 4", system_prompt)
@@ -1377,14 +1860,11 @@ def post(self, json: JSON) -> FakeResponse:
)
retrieved = store.retrieve("Celune", "what is my test word?")
- payload = cast(JSON, engine.vision.payload)
- system_prompt = cast(str, payload["system"])
self.assertEqual(
[record.content for record in retrieved],
["my test word is moonlight"],
)
- self.assertIn("my test word is moonlight", system_prompt)
def test_persona_prompt_builder_includes_short_term_summary_when_present(
self,
@@ -1406,7 +1886,7 @@ def test_persona_prompt_builder_includes_short_term_summary_when_present(
context = pipeline.build_persona_context(cast(Celune, engine), "Continue.")
prompt = PersonaPromptBuilder.build(context)
- self.assertIn("", prompt)
+ self.assertIn("", prompt)
self.assertIn("Summary:", prompt)
self.assertIn(
"The user and character already discussed the archive.",
@@ -1414,7 +1894,6 @@ def test_persona_prompt_builder_includes_short_term_summary_when_present(
)
self.assertIn("assistant: We reviewed the archive.", prompt)
self.assertIn("user: And after that?", prompt)
- self.assertNotIn("What did we cover?", prompt)
def test_persona_messages_include_pending_attachments(self) -> None:
"""Verify visual attachments are sent in the next persona user turn."""
@@ -1562,9 +2041,7 @@ def post(self, json: JSON) -> FakeResponse:
self.assertEqual(engine.persona_attachments, [])
first_payload = engine.vision.payloads[0]
- first_system = cast(str, first_payload["system"])
first_messages = cast(list[JSON], first_payload["messages"])
- self.assertIn("", first_system)
self.assertIsInstance(first_messages[-1]["content"], list)
second_payload = pipeline.build_persona_request(
@@ -1572,59 +2049,9 @@ def post(self, json: JSON) -> FakeResponse:
)
second_system = cast(str, second_payload["system"])
second_messages = cast(list[JSON], second_payload["messages"])
- self.assertIn("", second_system)
- self.assertIn(
- "Recent visual context from the last Persona request:",
- second_system,
- )
- self.assertIn("image: frame.png", second_system)
- self.assertIn("User request about that media: What is this?", second_system)
- self.assertNotIn("Character response about that media:", second_system)
- self.assertIn(
- "If you don't know something, say so in character",
- second_system,
- )
+ self.assertIn("", second_system)
self.assertEqual(second_messages[-1], {"role": "user", "content": "And now?"})
- def test_recent_visual_context_is_replaced_by_newer_visual_turn(self) -> None:
- """Verify only the most recent visual turn is carried forward as text context."""
- engine = make_pipeline_engine()
- engine.config = {}
- engine.current_character = "Fixture"
- engine.current_voice = "balanced"
-
- pipeline.remember_visual_context(
- [
- {
- "type": "image",
- "path": "file:///C:/Users/user/Pictures/old.png",
- "name": "old.png",
- }
- ],
- cast(Celune, engine),
- "What was in the old file?",
- )
- pipeline.remember_visual_context(
- [
- {
- "type": "video",
- "path": "https://example.com/clip.mp4",
- "name": "clip.mp4",
- }
- ],
- cast(Celune, engine),
- "And this clip?",
- )
-
- prompt = PersonaPromptBuilder.build(
- pipeline.build_persona_context(cast(Celune, engine), "Continue.")
- )
-
- self.assertIn("video: clip.mp4", prompt)
- self.assertIn("User request about that media: And this clip?", prompt)
- self.assertNotIn("Character response about that media:", prompt)
- self.assertNotIn("old.png", prompt)
-
def test_generation_worker_normalizes_each_split_chunk(self) -> None:
"""Verify normalization happens after splitting and before generation.
@@ -1861,7 +2288,7 @@ def generate_stream(
):
pipeline.generation_worker(cast(Celune, engine))
- self.assertEqual(queued_lengths, [48000, 96000])
+ self.assertEqual(queued_lengths, [48000, 48000, 48000])
self.assertGreater(engine.smart_buffer_generation_speed, 0.5)
self.assertLess(engine.smart_buffer_generation_speed, 1.3)
self.assertGreater(engine.smart_buffer_target_seconds, 0.0)
@@ -1923,9 +2350,74 @@ def generate_stream(
):
pipeline.generation_worker(cast(Celune, engine))
- self.assertEqual(queued_lengths, [144000])
+ self.assertEqual(queued_lengths, [48000, 48000, 48000])
self.assertEqual(engine.smart_buffer_target_seconds, float("inf"))
+ def test_playback_blocks_uses_true_50ms_chunks(self) -> None:
+ """Verify mixer block splitting uses real wall-clock block lengths."""
+ timing = pipeline.SpeechTiming(start_time=0.0)
+ chunk = pipeline.PlaybackChunk(
+ source_id=1,
+ audio=np.zeros((4800, 2), dtype=np.float32),
+ sample_rate=48000,
+ timing=timing,
+ )
+
+ blocks = pipeline._playback_blocks(chunk)
+
+ self.assertEqual(len(blocks), 2)
+ first_block, first_timing = blocks[0]
+ second_block, second_timing = blocks[1]
+ self.assertEqual(first_block.shape, (2400, 2))
+ self.assertEqual(second_block.shape, (2400, 2))
+ self.assertIs(first_timing, timing)
+ self.assertIsNone(second_timing)
+
+ def test_generation_worker_handles_save_false_without_concatenate_error(
+ self,
+ ) -> None:
+ """Verify silence analysis does not crash when output saving is disabled."""
+ engine = make_pipeline_engine()
+ engine.backend = SimpleNamespace(
+ generate_stream=lambda _model, **_kwargs: iter(
+ [(np.zeros((8, 2), dtype=np.float32), 48000, None)]
+ )
+ )
+ engine.model_lock = threading.Lock()
+ engine.model = mock.Mock()
+ engine.language = "en"
+ engine.chunk_size = 8
+ engine.voice_prompt = None
+ engine.current_voice = "balanced"
+ engine.speed = 1.0
+ engine.can_use_rubberband = False
+ engine.reverb = SimpleNamespace(
+ strength=0.0,
+ reset=mock.Mock(),
+ flush=mock.Mock(return_value=np.zeros((0, 2), dtype=np.float32)),
+ )
+ engine.queue_avail_callback = mock.Mock()
+ engine.sentinel = PipelineStates.TERMINATE
+ engine.exit_requested = False
+ engine.dev = False
+ engine.recently_saved = None
+
+ engine.text_queue.put(pipeline.SpeechRequest("hello", "hello", save=False))
+ engine.text_queue.put(engine.sentinel)
+
+ with (
+ mock.patch("celune.pipeline.split_text", return_value=["hello"]),
+ mock.patch(
+ "celune.pipeline.is_silent_utterance", return_value=(False, 0)
+ ) as silent_mock,
+ mock.patch("celune.pipeline._write_celune_flac") as write_mock,
+ ):
+ pipeline.generation_worker(cast(Celune, engine))
+
+ silent_mock.assert_called_once()
+ write_mock.assert_not_called()
+ self.assertIsNone(engine.recently_saved)
+
def test_split_text_breaks_long_unpunctuated_lines(self) -> None:
"""Verify long prose without punctuation still splits into chunks.
diff --git a/tests/test_runtime_and_ui_commands.py b/tests/test_runtime_and_ui_commands.py
index c6853b4..92d9288 100644
--- a/tests/test_runtime_and_ui_commands.py
+++ b/tests/test_runtime_and_ui_commands.py
@@ -2,14 +2,17 @@
"""Tests for runtime validation and lightweight UI commands."""
import sys
+import time
import logging
import tempfile
import warnings
-from typing import cast
+from typing import Optional, cast
from pathlib import Path
from types import SimpleNamespace
from unittest import mock, TestCase
+import numpy as np
+import sounddevice as sd
from textual import events
from textual.widgets import Button, Label, RichLog, TextArea
@@ -17,15 +20,18 @@
from celune import runtime
from celune.config import Config
from celune.celune import Celune
-from celune.backends.qwen3 import Qwen3
+from celune.backends.tts.qwen3 import Qwen3
from celune.constants import APP_NAME, JSONSerializable
+from celune.i18n import string
+from celune.ui import app as ui_app
from celune.ui.app import CeluneUI
from celune.ui.headless import CeluneHeadlessUI
from celune.ui import resources as ui_resources
+from celune.ui import terminal as ui_terminal
from celune.ui.commands import attachment_source, process_command
from celune.ui.theme import severity_color
-from tests.support import FakeBackend
+from tests.support import FakeBackend, FakeVCBackend
class RuntimeTests(TestCase):
@@ -176,9 +182,10 @@ def setUp(self) -> None:
(msg, severity)
)
self.ui.safe_log_dev = self.ui.safe_log
+ self.ui.refresh_vc_controls = mock.Mock()
self.ui.celune = SimpleNamespace(
config={"ipa": False},
- backend=SimpleNamespace(),
+ backend=FakeBackend,
voice_prompt=None,
persona_attachments=[],
can_use_rubberband=True,
@@ -186,7 +193,13 @@ def setUp(self) -> None:
reverb=SimpleNamespace(strength=0.0),
say=mock.Mock(return_value=True),
play=mock.Mock(return_value=True),
+ try_play_signal=mock.Mock(return_value=True),
vision=SimpleNamespace(enabled=True, talkback=True),
+ dev=False,
+ input_mode="text_to_speech",
+ vc_f0_condition=False,
+ vc_pitch_shift=0,
+ vc_backend=SimpleNamespace(),
)
def _process_command(self, command: str, args: list[str]) -> None:
@@ -238,6 +251,202 @@ def test_common_commands_update_state_and_validate_inputs(self) -> None:
self._process_command("reverb", ["150"])
self.assertEqual(self.logs[-1][1], "warning")
+ def test_backend_and_cevoice_commands_request_hot_reloads(self) -> None:
+ """Verify slash commands delegate backend and CEVOICE hot reloads into Celune."""
+ self.ui.celune.set_backend_and_wait = mock.Mock(return_value=True)
+ self.ui.celune.set_cevoice_and_wait = mock.Mock(return_value=True)
+
+ with mock.patch(
+ "celune.ui.commands.threading.Thread",
+ side_effect=self._thread_runs_immediately,
+ ):
+ self._process_command("backend", ["mini"])
+ self._process_command("cevoice", ["nova"])
+
+ self.ui.celune.set_backend_and_wait.assert_called_once_with("mini")
+ self.ui.celune.set_cevoice_and_wait.assert_called_once_with("nova")
+ self.assertEqual(self.logs[-2], ("Switched to backend: mini", "info"))
+ self.assertEqual(self.logs[-1], ("Character changed: nova", "info"))
+
+ def test_backend_command_uses_active_vc_backend_name_for_duplicate_guard(
+ self,
+ ) -> None:
+ """Verify the backend slash command checks the active VC runtime name too."""
+ self.ui.celune.input_mode = "voice_conversion"
+ self.ui.celune.vc_backend = FakeVCBackend(
+ log=lambda _msg, _severity="info": None
+ )
+ self.ui.celune._active_runtime_backend_name = mock.Mock(return_value="fake-vc")
+ self.ui.celune.set_backend_and_wait = mock.Mock(return_value=True)
+
+ self._process_command("backend", ["fake-vc"])
+
+ self.ui.celune._active_runtime_backend_name.assert_called_once_with()
+ self.ui.celune.set_backend_and_wait.assert_not_called()
+ self.assertEqual(self.logs[-1][1], "warning")
+
+ def test_voice_conversion_commands_update_seedvc_state(self) -> None:
+ """Verify VC slash commands update engine and backend state."""
+ self.ui.celune.input_mode = "voice_conversion"
+ self.ui.celune.vc_backend = SimpleNamespace(f0_condition=False, pitch_shift=0)
+
+ self._process_command("vcmode", ["sing"])
+ self._process_command("vcpitch", ["-5"])
+ self._process_command("vcpitch", ["clear"])
+
+ self.assertEqual(self.ui.celune.vc_f0_condition, True)
+ self.assertEqual(getattr(self.ui.celune.vc_backend, "f0_condition"), True)
+ self.assertEqual(self.ui.celune.vc_pitch_shift, 0)
+ self.assertEqual(getattr(self.ui.celune.vc_backend, "pitch_shift"), 0)
+ self.assertEqual(self.logs[-3], ("Voice conversion mode set to Sing.", "info"))
+ self.assertEqual(
+ self.logs[-2],
+ ("Voice conversion pitch shift set to -5 semitones.", "info"),
+ )
+ self.assertEqual(
+ self.logs[-1],
+ ("Voice conversion pitch shift set to 0 semitones.", "info"),
+ )
+ self.assertEqual(self.ui.refresh_vc_controls.call_count, 3)
+
+ def test_vc_command_submits_audio_file_through_engine_pipeline(self) -> None:
+ """Verify /vc decodes a file and submits it through Celune's VC pipeline."""
+ self.ui.celune.input_mode = "voice_conversion"
+ self.ui.celune.submit_audio = mock.Mock(return_value=True)
+ audio = np.ones((8, 2), dtype=np.float32)
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ source_path = Path(temp_dir) / "source.wav"
+ source_path.write_bytes(b"wav")
+
+ with (
+ mock.patch(
+ "celune.ui.commands.threading.Thread",
+ side_effect=self._thread_runs_immediately,
+ ),
+ mock.patch(
+ "celune.ui.commands.sf.read",
+ return_value=(audio, 48000),
+ ),
+ ):
+ self._process_command("vc", [str(source_path)])
+
+ cast(mock.Mock, self.ui.celune.submit_audio).assert_called_once_with(
+ audio,
+ 48000,
+ label="source.wav",
+ )
+ self.assertEqual(
+ self.logs[-1],
+ (f"Running voice conversion on {source_path}", "info"),
+ )
+
+ def test_voice_conversion_commands_validate_mode_and_pitch(self) -> None:
+ """Verify VC slash commands stay gated to VC mode and validate arguments."""
+ self._process_command("vc", ["sample.wav"])
+ self.assertEqual(
+ self.logs[-1],
+ ("This command is only available in voice conversion mode.", "warning"),
+ )
+
+ self._process_command("vcmode", ["sing"])
+ self.assertEqual(
+ self.logs[-1],
+ ("This command is only available in voice conversion mode.", "warning"),
+ )
+
+ self.ui.celune.input_mode = "voice_conversion"
+ self._process_command("vc", [])
+ self.assertEqual(self.logs[-1], ("Usage: /vc ", "warning"))
+
+ self._process_command("vc", ["missing.wav"])
+ self.assertEqual(
+ self.logs[-1],
+ ("Voice conversion input not found: missing.wav", "warning"),
+ )
+
+ self._process_command("vcmode", ["maybe"])
+ self.assertEqual(self.logs[-1], ("Usage: /vcmode ", "warning"))
+
+ self._process_command("vcpitch", ["13"])
+ self.assertEqual(
+ self.logs[-1],
+ ("Pitch shift must be between -12 and 12 semitones.", "warning"),
+ )
+
+ self._process_command("vcpitch", ["high"])
+ self.assertEqual(
+ self.logs[-1],
+ ("Usage: /vcpitch ", "warning"),
+ )
+
+ def test_vc_command_reports_decode_failure(self) -> None:
+ """Verify /vc surfaces decode errors cleanly."""
+ self.ui.celune.input_mode = "voice_conversion"
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ source_path = Path(temp_dir) / "broken.wav"
+ source_path.write_bytes(b"broken")
+
+ with (
+ mock.patch(
+ "celune.ui.commands.threading.Thread",
+ side_effect=self._thread_runs_immediately,
+ ),
+ mock.patch(
+ "celune.ui.commands.sf.read",
+ side_effect=RuntimeError("decode failed"),
+ ),
+ ):
+ self._process_command("vc", [str(source_path)])
+
+ self.assertEqual(
+ self.logs[-1],
+ ("Cannot read this voice conversion input: decode failed", "error"),
+ )
+
+ def test_cevoice_command_reports_failed_character_switch(self) -> None:
+ """Verify /cevoice warns when the requested pack cannot be loaded."""
+ self.ui.celune.set_cevoice_and_wait = mock.Mock(return_value=False)
+
+ with mock.patch(
+ "celune.ui.commands.threading.Thread",
+ side_effect=self._thread_runs_immediately,
+ ):
+ self._process_command("cevoice", ["invalid_character"])
+
+ self.ui.celune.set_cevoice_and_wait.assert_called_once_with("invalid_character")
+ self.assertEqual(
+ self.logs[-1],
+ ("Could not switch character to invalid_character.", "warning"),
+ )
+
+ def test_cevoice_command_rejects_already_loaded_character(self) -> None:
+ """Verify /cevoice warns instead of reloading the active pack."""
+ self.ui.celune.set_cevoice_and_wait = mock.Mock(return_value=True)
+
+ with (
+ mock.patch(
+ "celune.ui.commands.resolve_bundle_path",
+ return_value=Path("voices/nova.cevoice"),
+ ),
+ mock.patch(
+ "celune.ui.commands.active_bundle_path",
+ return_value=Path("voices/nova.cevoice"),
+ ),
+ mock.patch(
+ "celune.ui.commands.threading.Thread",
+ side_effect=self._thread_runs_immediately,
+ ),
+ ):
+ self._process_command("cevoice", ["nova"])
+
+ self.ui.celune.set_cevoice_and_wait.assert_not_called()
+ self.assertEqual(
+ self.logs[-1],
+ ("This character is already loaded.", "warning"),
+ )
+
def test_voiceprompt_command_is_blocked_when_model_lacks_instruction_control(
self,
) -> None:
@@ -362,6 +571,33 @@ def tearDown(self) -> None:
CeluneUI._instance = None
CeluneHeadlessUI._instance = None
+ def test_crossfade_vc_overlap_keeps_mono_audio_one_dimensional(self) -> None:
+ """Verify mono live VC overlap crossfades stay valid 1D audio."""
+ ui = CeluneUI()
+ previous = np.linspace(-1.0, -0.25, 4, dtype=np.float32)
+ current = np.linspace(0.25, 1.0, 4, dtype=np.float32)
+
+ blended = ui._crossfade_vc_overlap(previous, current)
+
+ self.assertEqual(blended.shape, (4,))
+ self.assertEqual(blended.dtype, np.float32)
+
+ def test_crossfade_vc_overlap_upmixes_mixed_channel_shapes_to_stereo(self) -> None:
+ """Verify mixed mono/stereo live VC overlap crossfades return stereo audio."""
+ ui = CeluneUI()
+ previous = np.linspace(-1.0, 1.0, 4, dtype=np.float32)
+ current = np.column_stack(
+ (
+ np.linspace(1.0, 0.25, 4, dtype=np.float32),
+ np.linspace(-1.0, -0.25, 4, dtype=np.float32),
+ )
+ )
+
+ blended = ui._crossfade_vc_overlap(previous, current)
+
+ self.assertEqual(blended.shape, (4, 2))
+ self.assertEqual(blended.dtype, np.float32)
+
def test_textual_ui_requires_attached_celune_on_mount(self) -> None:
"""Verify the Textual UI fails clearly without an attached Celune."""
ui = CeluneUI()
@@ -371,8 +607,8 @@ def test_textual_ui_requires_attached_celune_on_mount(self) -> None:
):
ui.on_mount()
- def test_textual_ui_delays_stdio_redirects_until_runtime_is_ready(self) -> None:
- """Verify mount keeps real stdio until Celune explicitly enables capture."""
+ def test_textual_ui_mount_enables_stdio_redirects_before_runtime_load(self) -> None:
+ """Verify mount captures startup stdio before Celune begins loading."""
ui = CeluneUI()
fake_widgets = {
"#logs": RichLog(),
@@ -380,39 +616,145 @@ def test_textual_ui_delays_stdio_redirects_until_runtime_is_ready(self) -> None:
"#status": Label(),
"#resources": Label(),
"#style": Button(),
+ "#vc-mode": Button(),
+ "#vc-pitch": Button(),
"#progress": SimpleNamespace(update=lambda **_: None),
"#header": Label(),
}
- ui.celune = cast(Celune, SimpleNamespace(config={}, close=lambda: None))
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ config={},
+ close=lambda: None,
+ backend=FakeBackend,
+ ),
+ )
original_stdout = sys.stdout
original_stderr = sys.stderr
- with (
- mock.patch("celune.ui.app.colors.configure_theme"),
- mock.patch("celune.ui.app.default_loader", return_value=None),
- mock.patch("celune.ui.app.ui_resources.prime_usage"),
- mock.patch.object(
- ui,
- "query_one",
- side_effect=lambda selector, *_args: fake_widgets[selector],
- ),
- mock.patch.object(ui, "query", return_value=[]),
- mock.patch.object(ui, "set_interval"),
- mock.patch.object(ui, "set_focus") as set_focus,
- mock.patch.object(ui, "call_after_refresh"),
- mock.patch.object(ui, "safe_status"),
- mock.patch.object(ui, "update_resources"),
- mock.patch.object(ui, "_refresh_status"),
- mock.patch.object(ui, "_refresh_theme_text"),
- mock.patch.object(ui, "_refresh_logs"),
- ):
- ui.on_mount()
+ try:
+ with (
+ mock.patch("celune.ui.app.colors.configure_theme"),
+ mock.patch("celune.ui.app.default_loader", return_value=None),
+ mock.patch("celune.ui.app.ui_resources.prime_usage"),
+ mock.patch.object(
+ ui,
+ "query_one",
+ side_effect=lambda selector, *_args: fake_widgets[selector],
+ ),
+ mock.patch.object(ui, "query", return_value=[]),
+ mock.patch.object(ui, "set_interval"),
+ mock.patch.object(ui, "set_focus") as set_focus,
+ mock.patch.object(ui, "call_after_refresh"),
+ mock.patch.object(ui, "safe_status"),
+ mock.patch.object(ui, "update_resources"),
+ mock.patch.object(ui, "_refresh_status"),
+ mock.patch.object(ui, "_refresh_theme_text"),
+ mock.patch.object(ui, "_refresh_logs"),
+ ):
+ ui.on_mount()
- self.assertIs(sys.stdout, original_stdout)
- self.assertIs(sys.stderr, original_stderr)
- self.assertFalse(ui._runtime_log_capture_enabled)
- set_focus.assert_called_once_with(None)
+ self.assertIs(sys.stdout, ui._log_stdout)
+ self.assertIs(sys.stderr, ui._log_stderr)
+ self.assertTrue(ui._runtime_log_capture_enabled)
+ set_focus.assert_called_once_with(None)
+ finally:
+ ui.disable_runtime_log_capture()
+ sys.stdout = original_stdout
+ sys.stderr = original_stderr
+
+ def test_textual_ui_mount_binds_callbacks_for_attached_runtime(self) -> None:
+ """Verify an attached runtime adopts the Textual UI callbacks on mount."""
+ ui = CeluneUI()
+ fake_widgets = {
+ "#logs": RichLog(),
+ "#input": TextArea(),
+ "#status": Label(),
+ "#resources": Label(),
+ "#style": Button(),
+ "#vc-mode": Button(),
+ "#vc-pitch": Button(),
+ "#progress": SimpleNamespace(update=lambda **_: None),
+ "#header": Label(),
+ }
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ config={},
+ close=lambda: None,
+ backend=FakeBackend,
+ log_callback=None,
+ status_callback=None,
+ error_callback=None,
+ idle_callback=None,
+ queue_avail_callback=None,
+ voice_changed_callback=None,
+ change_input_state_callback=None,
+ change_voice_lock_state_callback=None,
+ progress_callback=None,
+ glow=SimpleNamespace(fatal=lambda: None),
+ ),
+ )
+
+ original_stdout = sys.stdout
+ original_stderr = sys.stderr
+
+ try:
+ with (
+ mock.patch("celune.ui.app.colors.configure_theme"),
+ mock.patch("celune.ui.app.default_loader", return_value=None),
+ mock.patch("celune.ui.app.ui_resources.prime_usage"),
+ mock.patch.object(
+ ui,
+ "query_one",
+ side_effect=lambda selector, *_args: fake_widgets[selector],
+ ),
+ mock.patch.object(ui, "query", return_value=[]),
+ mock.patch.object(ui, "set_interval"),
+ mock.patch.object(ui, "set_focus"),
+ mock.patch.object(ui, "call_after_refresh"),
+ mock.patch.object(ui, "update_resources"),
+ mock.patch.object(ui, "_refresh_status"),
+ mock.patch.object(ui, "_refresh_theme_text"),
+ mock.patch.object(ui, "_refresh_logs"),
+ ):
+ ui.on_mount()
+
+ self.assertIs(ui.celune.log_callback.__self__, ui)
+ self.assertIs(ui.celune.log_callback.__func__, CeluneUI.tts_log)
+ self.assertIs(ui.celune.status_callback.__self__, ui)
+ self.assertIs(ui.celune.status_callback.__func__, CeluneUI.safe_status)
+ self.assertIs(ui.celune.error_callback.__self__, ui)
+ self.assertIs(ui.celune.error_callback.__func__, CeluneUI.error)
+ self.assertIs(ui.celune.idle_callback.__self__, ui)
+ self.assertIs(ui.celune.idle_callback.__func__, CeluneUI.tts_idle)
+ self.assertIs(ui.celune.queue_avail_callback.__self__, ui)
+ self.assertIs(
+ ui.celune.queue_avail_callback.__func__,
+ CeluneUI.tts_queue_avail,
+ )
+ self.assertIs(ui.celune.voice_changed_callback.__self__, ui)
+ self.assertIs(
+ ui.celune.voice_changed_callback.__func__,
+ CeluneUI.tts_voice_changed,
+ )
+ self.assertIs(ui.celune.change_input_state_callback.__self__, ui)
+ self.assertIs(
+ ui.celune.change_input_state_callback.__func__,
+ CeluneUI.change_input_state,
+ )
+ self.assertIs(ui.celune.change_voice_lock_state_callback.__self__, ui)
+ self.assertIs(
+ ui.celune.change_voice_lock_state_callback.__func__,
+ CeluneUI.change_voice_lock_state,
+ )
+ self.assertIs(ui.celune.progress_callback.__self__, ui)
+ self.assertIs(ui.celune.progress_callback.__func__, CeluneUI.safe_progress)
+ finally:
+ ui.disable_runtime_log_capture()
+ sys.stdout = original_stdout
+ sys.stderr = original_stderr
def test_runtime_log_capture_restores_stdio_after_shutdown(self) -> None:
"""Verify explicit runtime capture swaps and restores stdio cleanly."""
@@ -440,6 +782,165 @@ def test_runtime_log_capture_restores_stdio_after_shutdown(self) -> None:
sys.stdout = original_stdout
sys.stderr = original_stderr
+ def test_runtime_log_capture_preserves_original_terminal_passthrough(self) -> None:
+ """Verify runtime capture keeps ANSI passthrough bound to the original terminal."""
+ ui = CeluneUI()
+ ui.safe_log = lambda *_args, **_kwargs: None
+ original_stdout = sys.stdout
+ original_stderr = sys.stderr
+ terminal = mock.Mock()
+ terminal.isatty.return_value = True
+ redirected_stdout = mock.Mock()
+ redirected_stderr = mock.Mock()
+ redirected_stdout.isatty.return_value = True
+ redirected_stderr.isatty.return_value = True
+ ui._old_stdout = terminal
+ ui._old_stderr = terminal
+
+ try:
+ sys.stdout = redirected_stdout
+ sys.stderr = redirected_stderr
+
+ with mock.patch.object(ui, "_install_runtime_log_redirects"):
+ ui.enable_runtime_log_capture()
+
+ self.assertIs(ui._old_stdout, terminal)
+ self.assertIs(ui._old_stderr, terminal)
+ self.assertIsNotNone(ui._log_stdout)
+ self.assertIsNotNone(ui._log_stderr)
+ assert ui._log_stdout is not None
+ assert ui._log_stderr is not None
+ self.assertIs(ui._log_stdout.underlying_stdout, terminal)
+ self.assertIs(ui._log_stdout.underlying_stderr, terminal)
+ self.assertIs(ui._log_stderr.underlying_stdout, terminal)
+ self.assertIs(ui._log_stderr.underlying_stderr, terminal)
+ finally:
+ sys.stdout = original_stdout
+ sys.stderr = original_stderr
+
+ def test_log_redirect_ansi_forwards_and_flushes_underlying_stdout(self) -> None:
+ """Verify ANSI escape forwarding reaches the original terminal stream."""
+ stream = mock.Mock()
+ stream.isatty.return_value = True
+ redirect = ui_terminal.LogRedirect(
+ stdout=stream,
+ stderr=stream,
+ write_callback=lambda *_args, **_kwargs: None,
+ )
+
+ redirect.ansi(f"\x1b]2;{APP_NAME}\x07")
+
+ stream.write.assert_called_once_with(f"\x1b]2;{APP_NAME}\x07")
+ stream.flush.assert_called_once_with()
+
+ def test_log_redirect_reclassifies_warning_like_stdout_lines(self) -> None:
+ """Verify raw stdout warning text is surfaced with warning severity."""
+ stream = mock.Mock()
+ stream.isatty.return_value = True
+ captured: list[tuple[str, str]] = []
+ redirect = ui_terminal.LogRedirect(
+ stdout=stream,
+ stderr=stream,
+ write_callback=lambda msg, severity: captured.append((msg, severity)),
+ default_severity="info",
+ )
+
+ redirect.write(
+ "C:/tmp/hub.py:110: FutureWarning: TRANSFORMERS_CACHE is deprecated\n"
+ )
+
+ self.assertEqual(
+ captured,
+ [
+ (
+ "C:/tmp/hub.py:110: FutureWarning: TRANSFORMERS_CACHE is deprecated",
+ "warning",
+ )
+ ],
+ )
+
+ def test_log_redirect_suppresses_filtered_partial_stdout_lines(self) -> None:
+ """Verify redirected stdout suppressions match message content, not exact chunks."""
+ stream = mock.Mock()
+ stream.isatty.return_value = True
+ captured: list[tuple[str, str]] = []
+ redirect = ui_terminal.LogRedirect(
+ stdout=stream,
+ stderr=stream,
+ write_callback=lambda msg, severity: captured.append((msg, severity)),
+ default_severity="info",
+ filter_messages={"Loading weights from"},
+ )
+
+ redirect.write("Loading weights from C:/models/checkpoint.safetensors\n")
+
+ self.assertEqual(captured, [])
+
+ def test_log_redirect_suppresses_tqdm_progress_lines(self) -> None:
+ """Verify tqdm carriage-return progress lines are filtered out."""
+ stream = mock.Mock()
+ stream.isatty.return_value = True
+ captured: list[tuple[str, str]] = []
+ redirect = ui_terminal.LogRedirect(
+ stdout=stream,
+ stderr=stream,
+ write_callback=lambda msg, severity: captured.append((msg, severity)),
+ default_severity="info",
+ filter_messages={"it/s]"},
+ )
+
+ redirect.write(" 10%|# | 1/10 [00:00<00:03, 2.52it/s]\r")
+
+ self.assertEqual(captured, [])
+
+ def test_load_tts_writes_terminal_title_to_original_stdout(self) -> None:
+ """Verify the ready-state title reset targets the original terminal stream."""
+ ui = CeluneUI()
+ ui.safe_log = lambda *_args, **_kwargs: None
+ ui.safe_status = mock.Mock()
+ ui.tts_voice_changed = mock.Mock()
+ ui.safe_progress = mock.Mock()
+ ui.change_input_state = mock.Mock()
+ ui.change_voice_lock_state = mock.Mock()
+ ui._schedule_sleep_timer = mock.Mock()
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ load=lambda: True,
+ voices=("balanced", "bold"),
+ current_voice="balanced",
+ use_normalization=False,
+ dev=False,
+ glow=SimpleNamespace(fatal=lambda: None),
+ try_play_signal=mock.Mock(return_value=True),
+ ),
+ )
+ terminal = mock.Mock()
+ terminal.isatty.return_value = True
+ original_stdout = sys.stdout
+ original_stderr = sys.stderr
+ ui._old_stdout = terminal
+ ui._old_stderr = terminal
+
+ try:
+ with (
+ mock.patch("celune.ui.app.supports_ansi", return_value=True),
+ mock.patch.object(ui, "_install_runtime_log_redirects"),
+ mock.patch.object(
+ ui,
+ "call_from_thread",
+ side_effect=lambda callback, *args: callback(*args),
+ ),
+ ):
+ load_tts = getattr(CeluneUI.load_tts, "__wrapped__", CeluneUI.load_tts)
+ load_tts(ui)
+
+ terminal.write.assert_called_with(f"\x1b]2;{APP_NAME}\x07")
+ terminal.flush.assert_called()
+ finally:
+ sys.stdout = original_stdout
+ sys.stderr = original_stderr
+
def test_headless_ui_warns_without_attached_celune(self) -> None:
"""Verify headless mode warns before doing nothing without Celune."""
ui = CeluneHeadlessUI({"headless_nocolor": True})
@@ -484,6 +985,72 @@ def test_load_tts_marks_ui_error_when_startup_returns_false(self) -> None:
self.assertEqual(ui.style_button.disabled, True)
self.assertFalse(ui._fatal_error_active)
+ def test_load_tts_enters_ui_test_mode_without_error_for_fake_backend(self) -> None:
+ """Verify fake-backend UI test mode does not present as a startup failure."""
+ ui = CeluneUI()
+ ui.safe_status = mock.Mock()
+ ui.safe_progress = mock.Mock()
+ ui.change_input_state = mock.Mock()
+ ui.change_voice_lock_state = mock.Mock()
+ ui.error = mock.Mock()
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ load=lambda: True,
+ voices=(),
+ current_voice=None,
+ use_normalization=False,
+ backend=SimpleNamespace(is_fake=True),
+ dev=False,
+ glow=SimpleNamespace(fatal=lambda: None),
+ try_play_signal=mock.Mock(return_value=True),
+ ),
+ )
+
+ load_tts = getattr(CeluneUI.load_tts, "__wrapped__", CeluneUI.load_tts)
+ patched_modules = dict(sys.modules)
+ patched_modules.pop("pytest", None)
+ with mock.patch("celune.ui.app.sys.modules", patched_modules):
+ load_tts(ui)
+
+ ui.safe_status.assert_called_once_with(string("ui.test_mode_active"))
+ ui.safe_progress.assert_called_once_with(1, 1)
+ ui.change_input_state.assert_called_once_with(locked=True)
+ ui.change_voice_lock_state.assert_called_once_with(locked=True)
+ ui.error.assert_not_called()
+ self.assertNotEqual(ui.cur_state, "error")
+
+ def test_submit_text_is_noop_in_ui_test_mode(self) -> None:
+ """Verify interactive fake-backend UI test mode ignores submitted input."""
+ ui = CeluneUI()
+ ui.input_box = TextArea()
+ ui.input_box.load_text("hello world")
+ ui.safe_status = mock.Mock()
+ ui.process_command = mock.Mock()
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ backend=SimpleNamespace(is_fake=True),
+ cur_state="idle",
+ sleeping=False,
+ config={},
+ think=mock.Mock(),
+ say=mock.Mock(),
+ ),
+ )
+
+ patched_modules = dict(sys.modules)
+ patched_modules.pop("pytest", None)
+ with mock.patch("celune.ui.app.sys.modules", patched_modules):
+ handled = ui._submit_text(ui.input_box.text)
+
+ self.assertTrue(handled)
+ self.assertEqual(ui.input_box.text, "")
+ ui.safe_status.assert_called_once_with(string("ui.test_mode_active"))
+ ui.process_command.assert_not_called()
+ ui.celune.think.assert_not_called()
+ ui.celune.say.assert_not_called()
+
def test_tts_idle_does_not_recover_error_state_before_runtime_ready(self) -> None:
"""Verify signal callbacks cannot revert a failed startup back to idle."""
ui = CeluneUI()
@@ -510,6 +1077,36 @@ def test_tts_idle_does_not_recover_error_state_before_runtime_ready(self) -> Non
self.assertEqual(ui.input_box.placeholder, "Please wait")
self.assertEqual(ui.style_button.disabled, True)
+ def test_tts_idle_keeps_controls_locked_while_runtime_is_reloading(self) -> None:
+ """Verify idle playback callbacks do not unlock the UI mid-reload."""
+ ui = CeluneUI()
+ ui.celune_ready = True
+ ui.cur_state = "idle"
+ ui.input_box = TextArea()
+ ui.style_button = Button("Balanced")
+ ui.resources = cast(Label, None)
+ ui.status = Label()
+ ui.change_input_state = mock.Mock()
+ ui.change_voice_lock_state = mock.Mock()
+ ui.safe_status = mock.Mock()
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ locked=True,
+ sleeping=False,
+ is_in_tutorial=False,
+ voices=("balanced", "bold"),
+ cur_state="reloading",
+ ),
+ )
+
+ ui.tts_idle()
+
+ self.assertEqual(ui.celune.locked, True)
+ ui.change_input_state.assert_called_once_with(locked=True)
+ ui.change_voice_lock_state.assert_called_once_with(locked=True)
+ ui.safe_status.assert_not_called()
+
def test_on_button_pressed_ignores_voice_switch_when_no_voices_loaded(self) -> None:
"""Verify voice cycling is blocked cleanly when startup left no voices loaded."""
ui = CeluneUI()
@@ -540,6 +1137,7 @@ def test_textual_resource_footer_only_advertises_ctrl_q_exit(self) -> None:
is_in_tutorial=False,
config={"theme": "dark"},
backend=SimpleNamespace(current_seed=None),
+ input_mode="text_to_speech",
),
)
@@ -547,6 +1145,286 @@ def test_textual_resource_footer_only_advertises_ctrl_q_exit(self) -> None:
exit_page = next(page for page in pages if "CTRL+Q exit" in page)
self.assertNotIn("CTRL+C", exit_page)
+ self.assertNotIn("CTRL+R toggle recording", pages)
+
+ def test_textual_resource_footer_advertises_ctrl_r_only_in_vc_mode(self) -> None:
+ """Verify the resource footer advertises recording only while VC mode is active."""
+ celune = cast(
+ Celune,
+ SimpleNamespace(
+ is_in_tutorial=False,
+ config={"theme": "dark"},
+ backend=SimpleNamespace(current_seed=None),
+ input_mode="voice_conversion",
+ ),
+ )
+
+ pages = ui_resources.resource_pages(celune, "celune")
+
+ self.assertIn("CTRL+R toggle recording", pages)
+
+ def test_ctrl_r_toggles_tui_vc_recording(self) -> None:
+ """Verify CTRL+R streams VC audio live and flushes the final tail on stop."""
+ ui = CeluneUI()
+ self.addCleanup(setattr, CeluneUI, "_instance", None)
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ input_mode="voice_conversion",
+ vc_backend=SimpleNamespace(),
+ convert_audio=mock.Mock(
+ side_effect=lambda audio, sample_rate, label=None, **_kwargs: (
+ SimpleNamespace(
+ audio=np.asarray(audio, dtype=np.float32).copy(),
+ sample_rate=sample_rate,
+ label=label or "Stereo Mix",
+ )
+ )
+ ),
+ play_audio=mock.Mock(return_value=True),
+ is_in_tutorial=False,
+ dev=False,
+ config={"input_device": "Stereo Mix (Realtek)"},
+ ),
+ )
+ ui.safe_log = mock.Mock()
+ ui.update_resources = mock.Mock()
+ captured_callback: Optional[ui_app._VCAudioCallback] = None
+
+ def invoke_captured_callback(
+ callback: ui_app._VCAudioCallback,
+ audio: np.ndarray,
+ ) -> None:
+ callback(
+ audio,
+ len(audio),
+ None,
+ None,
+ )
+
+ class FakeInputStream:
+ """Tiny input-stream fake for VC recording tests."""
+
+ def __init__(self, **kwargs) -> None:
+ nonlocal captured_callback
+ captured_callback = kwargs["callback"]
+ self.start = mock.Mock()
+ self.stop = mock.Mock()
+ self.close = mock.Mock()
+
+ with (
+ mock.patch(
+ "celune.ui.app.sd.query_devices",
+ return_value={
+ "max_input_channels": 2,
+ "default_samplerate": 48000,
+ "name": "Stereo Mix",
+ },
+ ) as mock_query_devices,
+ mock.patch(
+ "celune.ui.app.sd.InputStream",
+ side_effect=FakeInputStream,
+ ) as mock_input_stream,
+ ):
+ start_event = SimpleNamespace(
+ key="ctrl+r",
+ prevent_default=mock.Mock(),
+ stop=mock.Mock(),
+ )
+ ui.on_key(cast(events.Key, start_event))
+
+ if captured_callback is None or not callable(captured_callback):
+ self.fail("recording callback was not registered")
+ else:
+ invoke_captured_callback(
+ cast(
+ ui_app._VCAudioCallback,
+ captured_callback,
+ ),
+ np.ones((20000, 2), dtype=np.float32),
+ )
+ for _ in range(50):
+ if cast(mock.Mock, ui.celune.convert_audio).call_count >= 1:
+ break
+ time.sleep(0.01)
+ invoke_captured_callback(
+ cast(
+ ui_app._VCAudioCallback,
+ captured_callback,
+ ),
+ np.ones((8, 2), dtype=np.float32),
+ )
+
+ stop_event = SimpleNamespace(
+ key="ctrl+r",
+ prevent_default=mock.Mock(),
+ stop=mock.Mock(),
+ )
+ ui.on_key(cast(events.Key, stop_event))
+
+ mock_query_devices.assert_called_once_with(
+ device="Stereo Mix (Realtek)",
+ kind="input",
+ )
+ self.assertEqual(
+ mock_input_stream.call_args.kwargs["device"],
+ "Stereo Mix (Realtek)",
+ )
+ for _ in range(50):
+ if cast(mock.Mock, ui.celune.convert_audio).call_count >= 2:
+ break
+ time.sleep(0.01)
+
+ self.assertGreaterEqual(cast(mock.Mock, ui.celune.convert_audio).call_count, 2)
+ convert_calls = cast(mock.Mock, ui.celune.convert_audio).call_args_list
+ play_calls = cast(mock.Mock, ui.celune.play_audio).call_args_list
+ first_convert_args = convert_calls[0].args
+ final_convert_args = convert_calls[-1].args
+ expected_first_chunk_frames = 20000 - int(
+ 48000 * ui_app._VC_LIVE_STREAM_OVERLAP_SECONDS
+ )
+ self.assertEqual(first_convert_args[1], 48000)
+ self.assertEqual(final_convert_args[1], 48000)
+ self.assertEqual(
+ convert_calls[-1].kwargs["label"],
+ "Stereo Mix",
+ )
+ self.assertEqual(len(first_convert_args[0]), expected_first_chunk_frames)
+ self.assertGreater(len(final_convert_args[0]), 8)
+ self.assertGreaterEqual(len(play_calls), 2)
+ first_play_args = play_calls[0].args
+ final_play_args = play_calls[-1].args
+ self.assertLess(len(first_play_args[0]), len(first_convert_args[0]))
+ self.assertEqual(first_play_args[1], 48000)
+ self.assertEqual(final_play_args[1], 48000)
+
+ def test_vc_recording_feedback_spike_stops_live_stream(self) -> None:
+ """Verify a sudden microphone RMS spike stops live VC capture automatically."""
+ ui = CeluneUI()
+ self.addCleanup(setattr, CeluneUI, "_instance", None)
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ input_mode="voice_conversion",
+ vc_backend=SimpleNamespace(),
+ convert_audio=mock.Mock(
+ side_effect=lambda audio, sample_rate, label=None, **_kwargs: (
+ SimpleNamespace(
+ audio=np.asarray(audio, dtype=np.float32).copy(),
+ sample_rate=sample_rate,
+ label=label or "Stereo Mix",
+ )
+ )
+ ),
+ play_audio=mock.Mock(return_value=True),
+ is_in_tutorial=False,
+ dev=False,
+ ),
+ )
+ ui.safe_log = mock.Mock()
+ ui.update_resources = mock.Mock()
+ captured_callback: Optional[ui_app._VCAudioCallback] = None
+
+ class FakeInputStream:
+ """Tiny input-stream fake for VC feedback-stop tests."""
+
+ def __init__(self, **kwargs) -> None:
+ nonlocal captured_callback
+ captured_callback = kwargs["callback"]
+ self.start = mock.Mock()
+ self.stop = mock.Mock()
+ self.close = mock.Mock()
+
+ with (
+ mock.patch(
+ "celune.ui.app.sd.query_devices",
+ return_value={
+ "max_input_channels": 2,
+ "default_samplerate": 48000,
+ "name": "Stereo Mix",
+ },
+ ),
+ mock.patch("celune.ui.app.sd.InputStream", side_effect=FakeInputStream),
+ ):
+ start_event = SimpleNamespace(
+ key="ctrl+r",
+ prevent_default=mock.Mock(),
+ stop=mock.Mock(),
+ )
+ ui.on_key(cast(events.Key, start_event))
+
+ def missing_callback(
+ _audio: np.ndarray,
+ _frames: int,
+ _time_info: Optional[tuple[float, float, float]],
+ _status: Optional[sd.CallbackFlags],
+ ) -> None:
+ raise AssertionError("recording callback was not registered")
+
+ recording_callback = captured_callback or missing_callback
+
+ recording_callback(
+ np.full((16, 2), 0.08, dtype=np.float32),
+ 16,
+ None,
+ None,
+ )
+ recording_callback(
+ np.full((16, 2), 0.24, dtype=np.float32),
+ 16,
+ None,
+ None,
+ )
+
+ for _ in range(50):
+ if not ui._vc_recording_active():
+ break
+ time.sleep(0.01)
+
+ self.assertEqual(ui._vc_recording_active(), False)
+ self.assertTrue(
+ any(
+ "feedback" in call.args[0].lower()
+ for call in cast(mock.Mock, ui.safe_log).call_args_list
+ if call.args
+ )
+ )
+
+ def test_ctrl_r_wakes_sleeping_celune_without_starting_recording(self) -> None:
+ """Verify CTRL+R wakes a sleeping VC runtime instead of starting capture."""
+ ui = CeluneUI()
+ self.addCleanup(setattr, CeluneUI, "_instance", None)
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ input_mode="voice_conversion",
+ vc_backend=SimpleNamespace(),
+ sleeping=True,
+ cur_state="sleeping",
+ config={},
+ ),
+ )
+ ui.safe_status = mock.Mock()
+ ui.change_input_state = mock.Mock()
+ ui.wake_from_sleep = mock.Mock()
+ ui._cancel_sleep_timer = mock.Mock()
+ ui.toggle_vc_recording = mock.Mock(return_value=True)
+
+ start_event = SimpleNamespace(
+ key="ctrl+r",
+ prevent_default=mock.Mock(),
+ stop=mock.Mock(),
+ )
+
+ ui.on_key(cast(events.Key, start_event))
+
+ ui._cancel_sleep_timer.assert_called_once()
+ ui.safe_status.assert_called_once_with("Waking up")
+ ui.change_input_state.assert_called_once_with(locked=True)
+ ui.wake_from_sleep.assert_called_once_with()
+ ui.toggle_vc_recording.assert_not_called()
+ start_event.prevent_default.assert_called_once_with()
+ start_event.stop.assert_called_once_with()
def test_gpu_usage_handles_closed_stdout_pipe(self) -> None:
"""Verify resource polling ignores closed-pipe nvidia-smi failures."""
@@ -592,11 +1470,41 @@ def test_textual_input_lock_update_with_persona_on_ui_thread(self) -> None:
):
ui.change_input_state(locked=False)
- self.assertEqual(ui.input_box.placeholder, "Enter text to speak here")
+ self.assertEqual(ui.input_box.placeholder, string("ui.input_placeholder"))
self.assertEqual(ui.style_button.disabled, False)
available.assert_called_once_with()
thread_cls.return_value.start.assert_called_once()
+ def test_change_input_state_reveals_vc_buttons_after_backend_switch(self) -> None:
+ """Verify VC controls appear when the UI unlocks into voice conversion mode."""
+ ui = CeluneUI()
+ ui.input_box = TextArea()
+ ui.style_button = Button("Voice")
+ ui.vc_mode_button = Button(string("ui.vc_mode_talk"))
+ ui.vc_pitch_button = Button(string("ui.vc_pitch_button", value="+0"))
+ ui.resources = cast(Label, None)
+ ui._input_locked = True
+ ui.vc_mode_button.display = False
+ ui.vc_pitch_button.display = False
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ config={},
+ vc_backend=SimpleNamespace(),
+ vc_f0_condition=False,
+ vc_pitch_shift=0,
+ ),
+ )
+
+ with mock.patch("celune.ui.app.threading.Thread") as thread_cls:
+ ui.change_input_state(locked=False)
+
+ self.assertEqual(ui.vc_mode_button.display, True)
+ self.assertEqual(ui.vc_pitch_button.display, True)
+ self.assertEqual(ui.vc_mode_button.disabled, False)
+ self.assertEqual(ui.vc_pitch_button.disabled, False)
+ thread_cls.return_value.start.assert_called_once()
+
def test_placeholder_uses_loaded_persona_not_runtime_capability(self) -> None:
"""Verify the input placeholder reflects whether Persona actually loaded."""
ui = CeluneUI()
@@ -616,7 +1524,7 @@ def test_placeholder_uses_loaded_persona_not_runtime_capability(self) -> None:
)
ui._persona_available = ui.persona_loaded()
- self.assertEqual(ui.normal_input_placeholder(), "Enter text to speak here")
+ self.assertEqual(ui.normal_input_placeholder(), string("ui.input_placeholder"))
ui.celune = cast(
Celune,
@@ -625,23 +1533,97 @@ def test_placeholder_uses_loaded_persona_not_runtime_capability(self) -> None:
"vram": "high",
"persona": cast(JSONSerializable, persona_config),
},
- vision=object(),
+ vision=SimpleNamespace(),
),
)
ui._persona_available = ui.persona_loaded()
- self.assertEqual(ui.normal_input_placeholder(), "Say something...")
+ self.assertEqual(ui.normal_input_placeholder(), string("ui.say_placeholder"))
+
+ def test_placeholder_uses_voice_changer_text_when_vc_backend_is_selected(
+ self,
+ ) -> None:
+ """Verify the input placeholder follows VC backend selection."""
+ ui = CeluneUI()
+ ui.input_box = TextArea()
+ ui.style_button = Button("Voice")
+ ui.resources = cast(Label, None)
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ config={},
+ vc_backend=SimpleNamespace(),
+ vision=SimpleNamespace(),
+ ),
+ )
+
+ self.assertEqual(
+ ui.normal_input_placeholder(),
+ string("ui.voice_changer_placeholder"),
+ )
+
+ def test_refresh_vc_controls_hides_buttons_outside_voice_conversion_mode(
+ self,
+ ) -> None:
+ """Verify VC-only buttons collapse away while the UI is in TTS mode."""
+ ui = CeluneUI()
+ ui.vc_mode_button = Button(string("ui.vc_mode_talk"))
+ ui.vc_pitch_button = Button(string("ui.vc_pitch_button", value="+0"))
+ ui._cancel_vc_recording = mock.Mock()
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ vc_backend=None,
+ vc_f0_condition=False,
+ vc_pitch_shift=0,
+ ),
+ )
+
+ ui.refresh_vc_controls()
+
+ self.assertEqual(ui.vc_mode_button.display, False)
+ self.assertEqual(ui.vc_pitch_button.display, False)
+ self.assertEqual(ui.vc_mode_button.disabled, True)
+ self.assertEqual(ui.vc_pitch_button.disabled, True)
+ ui._cancel_vc_recording.assert_called_once_with(announce=False)
+
+ def test_refresh_vc_controls_shows_buttons_in_voice_conversion_mode(
+ self,
+ ) -> None:
+ """Verify VC-only buttons return when a VC backend is active."""
+ ui = CeluneUI()
+ ui._input_locked = False
+ ui.vc_mode_button = Button(string("ui.vc_mode_talk"))
+ ui.vc_pitch_button = Button(string("ui.vc_pitch_button", value="+0"))
+ ui._cancel_vc_recording = mock.Mock()
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(
+ vc_backend=SimpleNamespace(),
+ vc_f0_condition=True,
+ vc_pitch_shift=3,
+ ),
+ )
+
+ ui.refresh_vc_controls()
+
+ self.assertEqual(ui.vc_mode_button.display, True)
+ self.assertEqual(ui.vc_pitch_button.display, True)
+ self.assertEqual(ui.vc_mode_button.label, string("ui.vc_mode_sing"))
+ self.assertEqual(
+ ui.vc_pitch_button.label,
+ string("ui.vc_pitch_button", value="+3"),
+ )
+ self.assertEqual(ui.vc_mode_button.disabled, False)
+ self.assertEqual(ui.vc_pitch_button.disabled, False)
+ ui._cancel_vc_recording.assert_not_called()
def test_runtime_logger_warning_is_routed_into_ui_logs(self) -> None:
- """Verify known Python logger warnings do not bleed into the terminal."""
+ """Verify external Python logger warnings are routed into the UI logs."""
ui = CeluneUI()
captured: list[tuple[str, str]] = []
ui.safe_log = lambda msg, severity="info": captured.append((msg, severity))
logger = logging.getLogger("torch.utils.flop_counter")
- original_handlers = list(logger.handlers)
- original_propagate = logger.propagate
- self.addCleanup(setattr, logger, "handlers", original_handlers)
- self.addCleanup(setattr, logger, "propagate", original_propagate)
ui.install_runtime_log_redirects()
self.addCleanup(ui._remove_runtime_log_redirects)
@@ -668,11 +1650,6 @@ def test_runtime_warning_capture_routes_py_warnings_triton_message(self) -> None
ui.safe_log = lambda msg, severity="info": captured.append((msg, severity))
logger = logging.getLogger("py.warnings")
- original_handlers = list(logger.handlers)
- original_propagate = logger.propagate
- self.addCleanup(setattr, logger, "handlers", original_handlers)
- self.addCleanup(setattr, logger, "propagate", original_propagate)
-
ui.install_runtime_log_redirects()
self.addCleanup(ui._remove_runtime_log_redirects)
@@ -692,6 +1669,64 @@ def test_runtime_warning_capture_routes_py_warnings_triton_message(self) -> None
],
)
+ def test_runtime_huggingface_logger_error_is_routed_into_ui_logs(self) -> None:
+ """Verify Hugging Face logger errors are routed into the UI log widget."""
+ ui = CeluneUI()
+ captured: list[tuple[str, str]] = []
+ ui.safe_log = lambda msg, severity="info": captured.append((msg, severity))
+
+ logger = logging.getLogger("huggingface_hub")
+ ui.install_runtime_log_redirects()
+ self.addCleanup(ui._remove_runtime_log_redirects)
+
+ logger.error("download failed because the connection dropped")
+
+ self.assertEqual(
+ captured,
+ [
+ (
+ "Internal runtime error: download failed because the "
+ "connection dropped",
+ "error",
+ )
+ ],
+ )
+
+ def test_runtime_global_log_redirect_captures_unlisted_external_logger(
+ self,
+ ) -> None:
+ """Verify arbitrary external loggers are captured without per-backend wiring."""
+ ui = CeluneUI()
+ captured: list[tuple[str, str]] = []
+ ui.safe_log = lambda msg, severity="info": captured.append((msg, severity))
+
+ logger = logging.getLogger("some.third_party.backend")
+
+ ui.install_runtime_log_redirects()
+ self.addCleanup(ui._remove_runtime_log_redirects)
+
+ logger.warning("backend emitted a warning")
+
+ self.assertEqual(
+ captured,
+ [("Internal runtime warning: backend emitted a warning", "warning")],
+ )
+
+ def test_runtime_logger_suppresses_filtered_messages(self) -> None:
+ """Verify runtime logger redirection honors the shared suppression list."""
+ ui = CeluneUI()
+ captured: list[tuple[str, str]] = []
+ ui.safe_log = lambda msg, severity="info": captured.append((msg, severity))
+
+ logger = logging.getLogger("some.third_party.backend")
+
+ ui.install_runtime_log_redirects()
+ self.addCleanup(ui._remove_runtime_log_redirects)
+
+ logger.warning("Loading weights from C:/models/checkpoint.safetensors")
+
+ self.assertEqual(captured, [])
+
def test_safe_status_marquees_long_text_for_narrow_status_label(self) -> None:
"""Verify long status text scrolls instead of clipping."""
@@ -873,6 +1908,36 @@ def test_wrapped_fatal_glow_activates_error_theme(self) -> None:
self.assertTrue(ui._fatal_error_active)
self.assertEqual(ui.theme, "celune_error")
+ def test_fatal_theme_stays_pinned_after_later_nonfatal_status_updates(self) -> None:
+ """Verify later routine events cannot clear the fatal UI theme once activated."""
+ ui = CeluneUI()
+ ui.status = Label()
+ ui.celune = cast(
+ Celune,
+ SimpleNamespace(glow=SimpleNamespace(fatal=mock.Mock())),
+ )
+
+ ui.wrap_runtime_fatal_glow()
+ ui.celune.glow.fatal()
+ ui.safe_status("Idle")
+ ui.safe_status("Speaking")
+
+ self.assertTrue(ui._fatal_error_active)
+ self.assertEqual(ui.theme, "celune_error")
+
+ def test_fatal_status_text_ignores_later_idle_updates(self) -> None:
+ """Verify fatal UI status text is not overwritten by later normal lifecycle events."""
+ ui = CeluneUI()
+ ui.status = Label()
+ ui.safe_status("Celune could not warm up", "error")
+ ui._fatal_error_active = True
+
+ ui.safe_status("Idle")
+ ui.safe_status("Speaking")
+
+ self.assertEqual(ui._status_text, "Celune could not warm up")
+ self.assertEqual(ui.status_severity, "error")
+
def test_runtime_error_themes_cover_dark_and_light_modes(self) -> None:
"""Verify both dedicated runtime error themes are registered correctly."""
ui = CeluneUI()
diff --git a/tests/test_runtime_paths.py b/tests/test_runtime_paths.py
index f302985..126ca0f 100644
--- a/tests/test_runtime_paths.py
+++ b/tests/test_runtime_paths.py
@@ -12,7 +12,15 @@
from textual.widgets import RichLog
from celune.constants import APP_SLUG
-from celune.paths import ensure_config_path, project_root, running_compiled
+from celune.paths import (
+ configure_huggingface_cache_environment,
+ configure_huggingface_runtime,
+ ensure_config_path,
+ huggingface_home_dir,
+ huggingface_hub_cache_dir,
+ project_root,
+ running_compiled,
+)
from celune.persona.memory import default_memory_dir
from celune.cevoice import bundled_voices_dir, default_bundle_path
from celune.ui.app import CeluneUI
@@ -57,6 +65,117 @@ def test_default_memory_dir_uses_runtime_memory_directory(self) -> None:
with mock.patch("celune.persona.memory.memory_data_dir", return_value=expected):
self.assertEqual(default_memory_dir(), expected)
+ def test_huggingface_cache_dirs_live_in_runtime_data(self) -> None:
+ """Verify Celune's default Hugging Face caches live under user data."""
+ expected_root = Path("C:/runtime-data")
+
+ with mock.patch("celune.paths.user_data_dir", return_value=str(expected_root)):
+ self.assertEqual(
+ huggingface_home_dir(),
+ expected_root / "huggingface",
+ )
+ self.assertEqual(
+ huggingface_hub_cache_dir(),
+ expected_root / "huggingface" / "hub",
+ )
+
+ def test_huggingface_cache_environment_defaults_to_runtime_data(self) -> None:
+ """Verify Celune points Hugging Face caches at the runtime data directory."""
+ expected_root = Path("C:/runtime-data")
+
+ with (
+ mock.patch("celune.paths.user_data_dir", return_value=str(expected_root)),
+ mock.patch("celune.paths.running_compiled", return_value=True),
+ mock.patch.dict(os.environ, {}, clear=True),
+ ):
+ configure_huggingface_cache_environment()
+
+ self.assertEqual(
+ os.environ["HF_HOME"],
+ str(expected_root / "huggingface"),
+ )
+ self.assertEqual(
+ os.environ["HF_HUB_CACHE"],
+ str(expected_root / "huggingface" / "hub"),
+ )
+
+ def test_huggingface_cache_environment_respects_existing_overrides(self) -> None:
+ """Verify explicit cache env vars are preserved."""
+ existing = {
+ "HF_HOME": "X:/hf-home",
+ "HF_HUB_CACHE": "X:/hf-hub",
+ }
+
+ with (
+ mock.patch("celune.paths.running_compiled", return_value=True),
+ mock.patch.dict(os.environ, existing.copy(), clear=True),
+ ):
+ configure_huggingface_cache_environment()
+ self.assertEqual(os.environ["HF_HOME"], existing["HF_HOME"])
+ self.assertEqual(os.environ["HF_HUB_CACHE"], existing["HF_HUB_CACHE"])
+
+ def test_huggingface_cache_environment_skips_source_tree_imports(self) -> None:
+ """Verify source-tree runs keep the host Hugging Face cache defaults."""
+ with (
+ mock.patch("celune.paths.running_compiled", return_value=False),
+ mock.patch.dict(os.environ, {}, clear=True),
+ ):
+ configure_huggingface_cache_environment()
+ self.assertNotIn("HF_HOME", os.environ)
+ self.assertNotIn("HF_HUB_CACHE", os.environ)
+
+ def test_huggingface_cache_environment_clears_celune_portable_defaults(
+ self,
+ ) -> None:
+ """Verify source-tree runs clear Celune-owned portable cache defaults."""
+ expected_root = Path("C:/runtime-data")
+
+ with (
+ mock.patch("celune.paths.user_data_dir", return_value=str(expected_root)),
+ mock.patch("celune.paths.running_compiled", return_value=False),
+ mock.patch.dict(
+ os.environ,
+ {
+ "HF_HOME": str(expected_root / "huggingface"),
+ "HF_HUB_CACHE": str(expected_root / "huggingface" / "hub"),
+ },
+ clear=True,
+ ),
+ ):
+ configure_huggingface_cache_environment()
+ self.assertNotIn("HF_HOME", os.environ)
+ self.assertNotIn("HF_HUB_CACHE", os.environ)
+
+ def test_huggingface_cache_environment_keeps_non_celune_overrides(
+ self,
+ ) -> None:
+ """Verify source-tree runs preserve unrelated explicit Hugging Face overrides."""
+ existing = {
+ "HF_HOME": "X:/hf-home",
+ "HF_HUB_CACHE": "X:/hf-hub",
+ }
+
+ with (
+ mock.patch("celune.paths.running_compiled", return_value=False),
+ mock.patch.dict(os.environ, existing.copy(), clear=True),
+ ):
+ configure_huggingface_cache_environment()
+ self.assertEqual(os.environ["HF_HOME"], existing["HF_HOME"])
+ self.assertEqual(os.environ["HF_HUB_CACHE"], existing["HF_HUB_CACHE"])
+
+ def test_huggingface_runtime_disables_global_progress_bars(self) -> None:
+ """Verify Celune suppresses Hugging Face progress bars without muting logs."""
+ with (
+ mock.patch("celune.paths.disable_progress_bar") as disable_transformers,
+ mock.patch("celune.paths.disable_progress_bars") as disable_hub,
+ mock.patch.dict(os.environ, {}, clear=True),
+ ):
+ configure_huggingface_runtime()
+ self.assertEqual(os.environ["HF_HUB_DISABLE_PROGRESS_BARS"], "1")
+
+ disable_transformers.assert_called_once_with()
+ disable_hub.assert_called_once_with()
+
def test_format_error_writes_traceback_to_runtime_directory(self) -> None:
"""Verify developer tracebacks are saved via the runtime path helper.
diff --git a/uv.lock b/uv.lock
index fbef80b..c28607d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -10,6 +10,15 @@ resolution-markers = [
"python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
+[[package]]
+name = "absl-py"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" },
+]
+
[[package]]
name = "accelerate"
version = "1.12.0"
@@ -197,6 +206,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
]
+[[package]]
+name = "asttokens"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
+]
+
[[package]]
name = "attrs"
version = "26.1.0"
@@ -330,13 +348,11 @@ wheels = [
[[package]]
name = "celune"
-version = "4.1.0"
+version = "4.2.0"
source = { virtual = "." }
dependencies = [
{ name = "accelerate" },
{ name = "bitsandbytes" },
- { name = "dots-tts" },
- { name = "faster-qwen3-tts" },
{ name = "hf-xet" },
{ name = "httpx" },
{ name = "huggingface-hub" },
@@ -348,7 +364,6 @@ dependencies = [
{ name = "openrgb-python" },
{ name = "pedalboard" },
{ name = "platformdirs" },
- { name = "pocket-tts" },
{ name = "psutil" },
{ name = "pyrubberband" },
{ name = "pyyaml" },
@@ -363,7 +378,6 @@ dependencies = [
{ name = "torchaudio" },
{ name = "torchvision" },
{ name = "transformers" },
- { name = "voxcpm" },
{ name = "yt-dlp" },
]
@@ -374,6 +388,21 @@ api = [
{ name = "python-multipart" },
{ name = "uvicorn" },
]
+dotstts = [
+ { name = "dots-tts" },
+]
+mini = [
+ { name = "pocket-tts" },
+]
+qwen3 = [
+ { name = "faster-qwen3-tts" },
+]
+seed-vc = [
+ { name = "seed-vc" },
+]
+voxcpm2 = [
+ { name = "voxcpm" },
+]
[package.dev-dependencies]
dev = [
@@ -392,9 +421,9 @@ dev = [
requires-dist = [
{ name = "accelerate" },
{ name = "bitsandbytes" },
- { name = "dots-tts", git = "https://github.com/celunah/dots.tts" },
+ { name = "dots-tts", marker = "extra == 'dotstts'", git = "https://github.com/celunah/dots.tts" },
{ name = "fastapi", marker = "extra == 'api'" },
- { name = "faster-qwen3-tts", specifier = ">=0.2.4" },
+ { name = "faster-qwen3-tts", marker = "extra == 'qwen3'", specifier = ">=0.2.4" },
{ name = "hf-xet" },
{ name = "httpx", specifier = ">=0.27.0,<0.29.0" },
{ name = "huggingface-hub", specifier = "<1.0.0" },
@@ -406,7 +435,7 @@ requires-dist = [
{ name = "openrgb-python" },
{ name = "pedalboard" },
{ name = "platformdirs" },
- { name = "pocket-tts", specifier = ">=2.1.0" },
+ { name = "pocket-tts", marker = "extra == 'mini'", specifier = ">=2.1.0" },
{ name = "psutil" },
{ name = "pydantic", marker = "extra == 'api'" },
{ name = "pyrubberband" },
@@ -416,6 +445,7 @@ requires-dist = [
{ name = "readchar" },
{ name = "rich" },
{ name = "scipy" },
+ { name = "seed-vc", marker = "extra == 'seed-vc'", git = "https://github.com/celunah/seed-vc.git" },
{ name = "sounddevice" },
{ name = "soundfile" },
{ name = "textual" },
@@ -424,10 +454,10 @@ requires-dist = [
{ name = "torchvision", specifier = "==0.26.0+cu128", index = "https://download.pytorch.org/whl/cu128" },
{ name = "transformers", specifier = "<5.0.0" },
{ name = "uvicorn", marker = "extra == 'api'" },
- { name = "voxcpm", specifier = ">=2.0.2" },
+ { name = "voxcpm", marker = "extra == 'voxcpm2'", specifier = ">=2.0.0" },
{ name = "yt-dlp" },
]
-provides-extras = ["api"]
+provides-extras = ["api", "qwen3", "voxcpm2", "dotstts", "mini", "seed-vc"]
[package.metadata.requires-dev]
dev = [
@@ -841,6 +871,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638, upload-time = "2026-01-26T04:13:18.546Z" },
]
+[[package]]
+name = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
[[package]]
name = "fastapi"
version = "0.136.3"
@@ -874,6 +913,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/20/5b274c5c5129640b08658519c930f8c123009fba1caca242b127393d3d07/faster_qwen3_tts-0.2.6-py3-none-any.whl", hash = "sha256:3881a41dc189f0a6e93fa047f376deffeb2fa84e888e7d570f79b3e2267765cc", size = 35058, upload-time = "2026-04-17T14:01:27.649Z" },
]
+[[package]]
+name = "ffmpy"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/d2/1c4c582d71bcc65c76fa69fab85de6257d50fdf6fd4a2317c53917e9a581/ffmpy-1.0.0.tar.gz", hash = "sha256:b12932e95435c8820f1cd041024402765f821971e4bae753b327fc02a6e12f8b", size = 5101, upload-time = "2025-11-11T06:24:23.856Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/56/dd3669eccebb6d8ac81e624542ebd53fe6f08e1b8f2f8d50aeb7e3b83f99/ffmpy-1.0.0-py3-none-any.whl", hash = "sha256:5640e5f0fd03fb6236d0e119b16ccf6522db1c826fdf35dcb87087b60fd7504f", size = 5614, upload-time = "2025-11-11T06:24:22.818Z" },
+]
+
[[package]]
name = "filelock"
version = "3.29.0"
@@ -883,6 +931,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
]
+[[package]]
+name = "fire"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "termcolor" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/00/f8d10588d2019d6d6452653def1ee807353b21983db48550318424b5ff18/fire-0.7.1.tar.gz", hash = "sha256:3b208f05c736de98fb343310d090dcc4d8c78b2a89ea4f32b837c586270a9cbf", size = 88720, upload-time = "2025-08-16T20:20:24.175Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/4c/93d0f85318da65923e4b91c1c2ff03d8a458cbefebe3bc612a6693c7906d/fire-0.7.1-py3-none-any.whl", hash = "sha256:e43fd8a5033a9001e7e2973bab96070694b9f12f2e0ecf96d4683971b5ab1882", size = 115945, upload-time = "2025-08-16T20:20:22.87Z" },
+]
+
[[package]]
name = "flatbuffers"
version = "25.12.19"
@@ -891,6 +951,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
]
+[[package]]
+name = "flatten-dict"
+version = "0.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/d0/34bf1bafa4d54e4ec20ca40856e991ffe7fdfd53831a2a36a7eb356508c8/flatten_dict-0.5.0.tar.gz", hash = "sha256:ca89664d0bc9552d525ee756726b5a755c17f65b5bf23d0a1f07841f181428b7", size = 9476, upload-time = "2026-04-28T14:23:44.979Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/9f/485516087cd8c44183aaf9ab850247a28e2e4a42a4d62eab77c21f673450/flatten_dict-0.5.0-py3-none-any.whl", hash = "sha256:c4bd2010052e4d33241433720d054322403fa7ad914fdc5cb1b31a713d4c561e", size = 9869, upload-time = "2026-04-28T14:23:45.695Z" },
+]
+
[[package]]
name = "fonttools"
version = "4.63.0"
@@ -1086,6 +1155,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/27/3d6dcadc8a3214d8522c1e7f6a19554e33659be44546d44a2f7572ac7d2a/groovy-0.1.2-py3-none-any.whl", hash = "sha256:7f7975bab18c729a257a8b1ae9dcd70b7cafb1720481beae47719af57c35fa64", size = 14090, upload-time = "2025-02-28T20:24:55.152Z" },
]
+[[package]]
+name = "grpcio"
+version = "1.81.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/85/07/9a979c81738863a738dc23d65177056e71fbb2db817740ed870b33434e7a/grpcio-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115", size = 6053264, upload-time = "2026-06-11T12:45:21.017Z" },
+ { url = "https://files.pythonhosted.org/packages/75/95/539706ca0d3bd40dbad583dc56fd883da941f37556b629132da5762781b9/grpcio-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3", size = 12052560, upload-time = "2026-06-11T12:45:23.652Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/44/f257b7e0bd69c93b06c6cb8ac8d1b901ccb42bedabd83c1a4c77a71f8810/grpcio-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2", size = 6595983, upload-time = "2026-06-11T12:45:26.963Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/f3/19782aa04c960968bef8c5539329d8e3bbc3364e2e46d19eb5e5cc5e43b7/grpcio-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7", size = 7303455, upload-time = "2026-06-11T12:45:29.707Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/8c/dea020b6d91508cd84463917a63149ec196ee7db505d032ae43fcb3303b9/grpcio-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0", size = 6809167, upload-time = "2026-06-11T12:45:32.52Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/c7/3030dd940408083bd32cd95d634777a71605ade4887154d93e8a89244946/grpcio-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3", size = 7412536, upload-time = "2026-06-11T12:45:35.403Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/dd/1172a9e42b168edcafefad6115346ef619a3fc02158bb170e66ced24bcdd/grpcio-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf", size = 8408276, upload-time = "2026-06-11T12:45:37.78Z" },
+ { url = "https://files.pythonhosted.org/packages/25/7a/71437c7f3596e5246155c515852795a85a1a8d228190212432b13b97a95d/grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c", size = 7849660, upload-time = "2026-06-11T12:45:40.627Z" },
+ { url = "https://files.pythonhosted.org/packages/65/40/7debc0da45d2efebafb82da75644be347497fe4ee250514b8cd3b86ae8bf/grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6", size = 4185819, upload-time = "2026-06-11T12:45:43.027Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" },
+ { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" },
+ { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -1202,6 +1302,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" },
]
+[[package]]
+name = "importlib-resources"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" },
+]
+
[[package]]
name = "inflect"
version = "7.5.0"
@@ -1224,6 +1333,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "ipython"
+version = "9.14.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "decorator" },
+ { name = "ipython-pygments-lexers" },
+ { name = "jedi" },
+ { name = "matplotlib-inline" },
+ { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit" },
+ { name = "psutil", marker = "sys_platform != 'emscripten'" },
+ { name = "pygments" },
+ { name = "stack-data" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e2/23/3a27530575643c8bb7bfc757a28e2e7ef80092afbf59a2bc5716320b6602/ipython-9.14.1.tar.gz", hash = "sha256:f913bf74df06d458e46ced84ca506c23797590d594b236fe60b14df213291e7b", size = 4433457, upload-time = "2026-06-05T08:12:34.921Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/22/58818a63eaf8982b67632b1bc20585c811611b15a8da19d6012323dc76a5/ipython-9.14.1-py3-none-any.whl", hash = "sha256:5d4a9ecaa3b10e6e5f269dd0948bdb58ca9cb851899cd23e07c320d3eb11613c", size = 627770, upload-time = "2026-06-05T08:12:33.045Z" },
+]
+
+[[package]]
+name = "ipython-pygments-lexers"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
+]
+
[[package]]
name = "iso639-lang"
version = "2.6.3"
@@ -1260,6 +1403,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/cc/49812faae67f9a24be6ddaf58a2cf7e8c3cbfcf5b762d9414f7103d2ea2c/jamo-0.4.1-py3-none-any.whl", hash = "sha256:d4b94fd23324c606ed2fbc4037c603e2c3a7ae9390c05d3473aea1ccb6b1c3fb", size = 9543, upload-time = "2017-11-06T19:28:49.624Z" },
]
+[[package]]
+name = "jedi"
+version = "0.20.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "parso" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
+]
+
[[package]]
name = "jieba"
version = "0.42.1"
@@ -1278,6 +1433,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
+[[package]]
+name = "jiwer"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "rapidfuzz" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/12/1e/963dfc249d5bbe88079b57a22556e981ddd9208e4b6116e48bdbfc01f26b/jiwer-4.0.0.tar.gz", hash = "sha256:ae9c051469102a61ef0927100baeeb4546f78d180c9b0948281d08eaf44c191e", size = 28074, upload-time = "2025-06-19T16:05:23.004Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/c9/172c525330c739a068c01050759a6f855ce16212db10a0359e690a03ac48/jiwer-4.0.0-py3-none-any.whl", hash = "sha256:7efaf0bd336b095d99ddef9dd67e1ee829d75d58aa2a81d9639870b01d6d95ea", size = 23034, upload-time = "2025-06-19T16:05:21.821Z" },
+]
+
[[package]]
name = "jmespath"
version = "0.10.0"
@@ -1296,6 +1464,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
+[[package]]
+name = "julius"
+version = "0.2.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "torch" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1d/c6/5c2aea2b11ead1680bb5b17c996def31f8ccade471cada37cdb93855e06f/julius-0.2.8.tar.gz", hash = "sha256:d691e651200930affea4f6c849c26e95fec846087281ae3d1a7757eac90253d5", size = 48081, upload-time = "2026-06-03T16:05:34.52Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/86/43/efdb0bcb07c47826fa55857cec0deb743f74cd83b6ba5ec9e413505a72e6/julius-0.2.8-py3-none-any.whl", hash = "sha256:6891235cbc355e629d839f87489bff8ca46e57a0e7cc35abb909c7a2aa538c25", size = 21819, upload-time = "2026-06-03T16:05:33.443Z" },
+]
+
[[package]]
name = "kaldifst"
version = "1.8.0"
@@ -1558,6 +1738,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/84/514da5bd3ee051e800caf1ce687b7727a92b643416cc678dc47d3eface97/marisa_trie-1.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:0b2e53f87c01b99c59cda37411a234c704a95d12f4787aeb29572fa9302f2b93", size = 146566, upload-time = "2026-04-08T07:17:15.175Z" },
]
+[[package]]
+name = "markdown"
+version = "3.10.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
+]
+
[[package]]
name = "markdown-it-py"
version = "4.2.0"
@@ -1575,6 +1764,15 @@ linkify = [
{ name = "linkify-it-py" },
]
+[[package]]
+name = "markdown2"
+version = "2.5.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/07d4a5fcaa5509221287d289323d75ac8eda5a5a4ac9de2accf7bbcc2b88/markdown2-2.5.5.tar.gz", hash = "sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664", size = 157249, upload-time = "2026-03-02T20:46:53.411Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/af/4b3891eb0a49d6cfd5cbf3e9bf514c943afc2b0f13e2c57cc57cd88ecc21/markdown2-2.5.5-py3-none-any.whl", hash = "sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941", size = 56250, upload-time = "2026-03-02T20:46:52.032Z" },
+]
+
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -1656,6 +1854,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" },
]
+[[package]]
+name = "matplotlib-inline"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
+]
+
[[package]]
name = "mccabe"
version = "0.7.0"
@@ -1826,6 +2036,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/d9/f7f9379981e39b8c2511c9e0326d212accacb82f12fbfdc1aa2ce2a7b2b6/multiprocess-0.70.16-py39-none-any.whl", hash = "sha256:a0bafd3ae1b732eac64be2e72038231c1ba97724b60b09400d68f229fcc2fbf3", size = 133351, upload-time = "2024-01-28T18:52:31.981Z" },
]
+[[package]]
+name = "munch"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/2b/45098135b5f9f13221820d90f9e0516e11a2a0f55012c13b081d202b782a/munch-4.0.0.tar.gz", hash = "sha256:542cb151461263216a4e37c3fd9afc425feeaf38aaa3025cd2a981fadb422235", size = 19089, upload-time = "2023-07-01T09:49:35.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/b3/7c69b37f03260a061883bec0e7b05be7117c1b1c85f5212c72c8c2bc3c8c/munch-4.0.0-py2.py3-none-any.whl", hash = "sha256:71033c45db9fb677a0b7eb517a4ce70ae09258490e419b0e7f00d1e386ecb1b4", size = 9950, upload-time = "2023-07-01T09:49:34.472Z" },
+]
+
[[package]]
name = "networkx"
version = "3.6.1"
@@ -2194,6 +2413,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" },
]
+[[package]]
+name = "parso"
+version = "0.8.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
+]
+
[[package]]
name = "pedalboard"
version = "0.9.23"
@@ -2221,6 +2449,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/20/3804be786e495d81b39e591b82f906ca3426071d9476060672402087a641/pedalboard-0.9.23-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b4592e949ebef6c413cbb9542dcbd7f6083f1641a59c6044eb618ee1eb701dd", size = 5040649, upload-time = "2026-05-15T23:43:36.972Z" },
]
+[[package]]
+name = "pexpect"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
+]
+
[[package]]
name = "pillow"
version = "12.2.0"
@@ -2323,6 +2563,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" },
]
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.52"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
+]
+
[[package]]
name = "propcache"
version = "0.5.2"
@@ -2385,17 +2637,11 @@ wheels = [
[[package]]
name = "protobuf"
-version = "7.35.0"
+version = "3.19.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/60/fd/5b1491d9e4b586d621c54f4c36b888714164b6875f8d6afa3f9072906a51/protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6", size = 458677, upload-time = "2026-05-19T23:02:29.197Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/51/d1/79bfd1f481469b661a2eddab551255536401892722189433282bfb13cfb1/protobuf-3.19.6.tar.gz", hash = "sha256:5f5540d57a43042389e87661c6eaa50f47c19c6176e8cf1c4f287aeefeccb5c4", size = 218071, upload-time = "2022-09-29T22:07:23.03Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda", size = 433225, upload-time = "2026-05-19T23:02:19.884Z" },
- { url = "https://files.pythonhosted.org/packages/8b/39/1c76c2da93f3c507e958e0aecee2391cc44d4625de6c728bbc555195b5a8/protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5", size = 328847, upload-time = "2026-05-19T23:02:22.3Z" },
- { url = "https://files.pythonhosted.org/packages/91/1a/39f7ce90a238c1a987a4d81ec26379e02ca0aff367de68e4a1fa474215b9/protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee", size = 344030, upload-time = "2026-05-19T23:02:23.591Z" },
- { url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011", size = 327130, upload-time = "2026-05-19T23:02:24.637Z" },
- { url = "https://files.pythonhosted.org/packages/8e/e5/e46adb0badc388bfb84877a5f9f026aff63f60e611016cf64dbe77e05446/protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6", size = 428946, upload-time = "2026-05-19T23:02:25.741Z" },
- { url = "https://files.pythonhosted.org/packages/a7/ab/547fbd9e16d879dd13c167478f8ae0a83a428008ca07a5e06acdc23ad473/protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201", size = 439996, upload-time = "2026-05-19T23:02:26.808Z" },
- { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" },
+ { url = "https://files.pythonhosted.org/packages/32/27/1141a8232723dcb10a595cc0ce4321dcbbd5215300bf4acfc142343205bf/protobuf-3.19.6-py2.py3-none-any.whl", hash = "sha256:14082457dc02be946f60b15aad35e9f5c69e738f80ebbc0900a19bc83734a5a4", size = 162648, upload-time = "2022-09-29T22:07:20.303Z" },
]
[[package]]
@@ -2420,6 +2666,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
+]
+
+[[package]]
+name = "pure-eval"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
+]
+
[[package]]
name = "pyahocorasick"
version = "2.3.1"
@@ -2606,6 +2870,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
]
+[[package]]
+name = "pyloudnorm"
+version = "0.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "scipy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/00/f915eaa75326f4209941179c2b93ac477f2040e4aeff5bb21d16eb8058f9/pyloudnorm-0.2.0.tar.gz", hash = "sha256:8bf597658ea4e1975c275adf490f6deb5369ea409f2901f939915efa4b681b16", size = 14037, upload-time = "2026-01-04T11:43:35.265Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/aa/b6/65a49a05614b2548edbba3aab118f2ebe7441dfd778accdcdce9f6567f20/pyloudnorm-0.2.0-py3-none-any.whl", hash = "sha256:9bb69afb904f59d007a7f9ba3d75d16fb8aeef35c44d6df822a9f192d69cf13f", size = 10879, upload-time = "2026-01-04T11:43:34.534Z" },
+]
+
[[package]]
name = "pynndescent"
version = "0.6.0"
@@ -2661,6 +2938,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/88/238aa67e353012d8dd999372aedada3f627bc9895d5d2a5305e7ffb1affd/pyrubberband-0.4.0-py3-none-any.whl", hash = "sha256:0a803191f38debbbdfd9135939c1f75999e98511f19867d357b84e191f139ca9", size = 4798, upload-time = "2024-09-30T17:52:59.621Z" },
]
+[[package]]
+name = "pystoi"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "scipy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bf/3d/1ae8bdb686c6aaaeef474aa6b790abbe38f42b61188b57a974dd9320e521/pystoi-0.4.1.tar.gz", hash = "sha256:1c6f50d6fbfee46b00c922458cdbd27228d9830ca81cea788fd600fc2f7de6e4", size = 10401, upload-time = "2023-12-29T16:48:05.726Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/22/60cd92bd3ec00948800984410f4cf5ded5bd8e9b715729f3642efe0edb3d/pystoi-0.4.1-py2.py3-none-any.whl", hash = "sha256:e277b671663d26d35a2416c9c8010a74084e6c3970354506398051a554896939", size = 8218, upload-time = "2023-12-29T16:48:03.748Z" },
+]
+
[[package]]
name = "pytest"
version = "9.0.3"
@@ -2689,6 +2979,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
[[package]]
name = "python-multipart"
version = "0.0.30"
@@ -2770,6 +3069,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/43/80f67e0336cb2fc725f8e06f7fe35c1d0fe946f4d2b8b2175e797e07349e/qwen_vl_utils-0.0.14-py3-none-any.whl", hash = "sha256:5e28657bfd031e56bd447c5901b58ddfc3835285ed100f4c56580e0ade054e96", size = 8120, upload-time = "2025-09-23T09:38:56.297Z" },
]
+[[package]]
+name = "randomname"
+version = "0.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "fire" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c2/525e9e9b458c3ca493d9bd0871f3ed9b51446d26fe82d462494de188f848/randomname-0.2.1.tar.gz", hash = "sha256:b79b98302ba4479164b0a4f87995b7bebbd1d91012aeda483341e3e58ace520e", size = 64242, upload-time = "2023-01-29T02:42:26.469Z" }
+
+[[package]]
+name = "rapidfuzz"
+version = "3.14.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" },
+ { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" },
+ { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" },
+ { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" },
+ { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" },
+ { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" },
+ { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" },
+ { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" },
+ { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" },
+ { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" },
+ { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" },
+ { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" },
+ { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" },
+ { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" },
+ { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" },
+ { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" },
+ { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" },
+]
+
[[package]]
name = "readchar"
version = "4.2.2"
@@ -2850,6 +3199,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
+[[package]]
+name = "resemblyzer"
+version = "0.1.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "librosa" },
+ { name = "numpy" },
+ { name = "scipy" },
+ { name = "torch" },
+ { name = "typing" },
+ { name = "webrtcvad" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fb/69/b71d45dca34c51467d0df29154f6eb8c19d4effb95dbbaeef6fc160b29de/Resemblyzer-0.1.4.tar.gz", hash = "sha256:ae98d6eaf1def4b91d353347f7d31f10c29e8e109b5196ab29b390293036ad19", size = 15747774, upload-time = "2023-10-12T09:34:57.809Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/b5/d897d5de5b2123a1ad5d32e8e007d83f76582f5c32c60992577263bd7c7e/Resemblyzer-0.1.4-py3-none-any.whl", hash = "sha256:8f12eb2f1a9982d32e8db7856de754709b59c93a77bcf0ff536584b619a9dd1f", size = 15747704, upload-time = "2023-10-12T09:34:48.452Z" },
+]
+
[[package]]
name = "rich"
version = "15.0.0"
@@ -2997,6 +3363,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
]
+[[package]]
+name = "seed-vc"
+version = "0.4.3"
+source = { git = "https://github.com/celunah/seed-vc.git#7d9610de5a737d9b8d7511cc0386b61ebdb4e9ee" }
+dependencies = [
+ { name = "accelerate" },
+ { name = "argbind" },
+ { name = "einops" },
+ { name = "ffmpy" },
+ { name = "flatten-dict" },
+ { name = "huggingface-hub" },
+ { name = "hydra-core" },
+ { name = "importlib-resources" },
+ { name = "ipython" },
+ { name = "jiwer" },
+ { name = "julius" },
+ { name = "librosa" },
+ { name = "markdown2" },
+ { name = "matplotlib" },
+ { name = "munch" },
+ { name = "numpy" },
+ { name = "protobuf" },
+ { name = "pydub" },
+ { name = "pyloudnorm" },
+ { name = "pystoi" },
+ { name = "python-dotenv" },
+ { name = "pyyaml" },
+ { name = "randomname" },
+ { name = "resemblyzer" },
+ { name = "rich" },
+ { name = "scipy" },
+ { name = "soundfile" },
+ { name = "tensorboard" },
+ { name = "torch" },
+ { name = "torch-stoi" },
+ { name = "torchaudio" },
+ { name = "torchvision" },
+ { name = "tqdm" },
+ { name = "transformers" },
+]
+
[[package]]
name = "semantic-version"
version = "2.10.0"
@@ -3183,6 +3590,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/7e/86bf0708c6c61e6bd08cb30d57b6b6f3ea74d9e561d599eabdeb913a0e2a/spaces-0.50.2-py3-none-any.whl", hash = "sha256:20ff5f40c5d2a7f68b5effc76eadcc9fdfaa8af18ab64f687bfdc110ad9bd1f3", size = 111280, upload-time = "2026-05-14T16:40:28.403Z" },
]
+[[package]]
+name = "stack-data"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "executing" },
+ { name = "pure-eval" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
+]
+
[[package]]
name = "standard-aifc"
version = "3.13.0"
@@ -3242,18 +3663,57 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
+[[package]]
+name = "tensorboard"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "absl-py" },
+ { name = "grpcio" },
+ { name = "markdown" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pillow" },
+ { name = "protobuf" },
+ { name = "setuptools" },
+ { name = "tensorboard-data-server" },
+ { name = "werkzeug" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680, upload-time = "2025-07-17T19:20:49.638Z" },
+]
+
+[[package]]
+name = "tensorboard-data-server"
+version = "0.7.2"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356, upload-time = "2023-10-23T21:23:32.16Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598, upload-time = "2023-10-23T21:23:33.714Z" },
+ { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" },
+]
+
[[package]]
name = "tensorboardx"
-version = "2.6.5"
+version = "2.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/48/a9/fc520ea91ab1f3ba51cbf3fe24f2b6364ed3b49046969e0868d46d6da372/tensorboardx-2.6.5.tar.gz", hash = "sha256:ca176db3997ee8c07d2eb77381225956a3fd1c10c91beafab1f17069adc47017", size = 4770195, upload-time = "2026-04-03T15:40:23.803Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/e3/c68897ee471518b1f8d3e53c602aec6f2b09092aa774da88764262f03f56/tensorboardX-2.6.tar.gz", hash = "sha256:d4c036964dd2deb075a1909832b276daa383eab3f9db519ad90b99f5aea06b0c", size = 91196, upload-time = "2023-02-12T17:54:36.343Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/87/0f/69fbab4c30b2f3a76e6de67585ea72a8eccf381751f5c0046b966fde9658/tensorboardx-2.6.5-py3-none-any.whl", hash = "sha256:c10b891d00af306537cb8b58a039b2ba41571f0da06f433a41c4ca8d6abe1373", size = 87510, upload-time = "2026-04-03T15:40:22.111Z" },
+ { url = "https://files.pythonhosted.org/packages/60/9f/d532d37f10ac7af136d4c2ba71e1fe7af0f3cc0cc076dfc05826171e9737/tensorboardX-2.6-py2.py3-none-any.whl", hash = "sha256:24a7cd076488de1e9d15ef25371b8ebf90c4f8f622af2477c611198f03f4a606", size = 114480, upload-time = "2023-02-12T17:54:34.617Z" },
+]
+
+[[package]]
+name = "termcolor"
+version = "3.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
]
[[package]]
@@ -3408,6 +3868,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/c5/9b4d756a7ada951e9b17dcc636f98ed1073c737ae809b150ef408afb6298/torch_complex-0.4.4-py3-none-any.whl", hash = "sha256:6ab4ecd4f3a16e3adb70a7f7cd2e769a9dfd07d7a8e27d04ff9c621ebbe34b13", size = 9125, upload-time = "2024-06-28T07:10:26.651Z" },
]
+[[package]]
+name = "torch-stoi"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "pystoi" },
+ { name = "torch" },
+ { name = "torchaudio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/47/a4/23be9a35b6c5b5c4547d24c52b07f4a5d55406a5ae43d9cce09f2352d75a/torch_stoi-0.2.3.tar.gz", hash = "sha256:228207b8d63548336c5520b156f1e6b30d3ae3db1fb3c41999f01aee087c5f85", size = 9224, upload-time = "2024-09-30T15:22:12.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/92/ead346e904390a53e71b5da2df7e7839abb16e967ba07fa15addf1f9f37c/torch_stoi-0.2.3-py3-none-any.whl", hash = "sha256:6eee85e33b42fe843a2150de46000f72e7b87cbeb19ae6ab9bbd94b6ec6b3cd2", size = 8146, upload-time = "2024-09-30T15:22:10.92Z" },
+]
+
[[package]]
name = "torchaudio"
version = "2.11.0+cu128"
@@ -3487,6 +3962,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
+[[package]]
+name = "traitlets"
+version = "5.15.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" },
+]
+
[[package]]
name = "transformers"
version = "4.57.3"
@@ -3590,6 +4074,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/56/59/dbe84f71186645b821cf63f71fc746e1455fbce074594a86f486bee8dc89/types_tqdm-4.67.3.20260518-py3-none-any.whl", hash = "sha256:4969f5b97257c6d082c163d7a0715fffe7a83cb6863e108eac45c3fb82305a68", size = 24586, upload-time = "2026-05-18T06:08:03.568Z" },
]
+[[package]]
+name = "typing"
+version = "3.10.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/1b/835d4431805939d2996f8772aca1d2313a57e8860fec0e48e8e7dfe3a477/typing-3.10.0.0.tar.gz", hash = "sha256:13b4ad211f54ddbf93e5901a9967b1e07720c1d1b78d596ac6a439641aa1b130", size = 78962, upload-time = "2021-05-01T18:03:58.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/5d/865e17349564eb1772688d8afc5e3081a5964c640d64d1d2880ebaed002d/typing-3.10.0.0-py3-none-any.whl", hash = "sha256:12fbdfbe7d6cca1a42e485229afcb0b0c8259258cfb919b8a5e2a5c953742f89", size = 26320, upload-time = "2021-05-01T18:03:56.398Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -3702,6 +4195,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/50/76e912427684f7e71d443d9542802ad33df8764ef3bba954b96feeab41ba/voxcpm-2.0.3-py3-none-any.whl", hash = "sha256:24da58a30d094a9e9a7ead450ae9cffda0d31eaeba620b61ad99179dd87e486b", size = 88270, upload-time = "2026-05-11T12:00:04.154Z" },
]
+[[package]]
+name = "wcwidth"
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
+]
+
+[[package]]
+name = "webrtcvad"
+version = "2.0.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/89/34/e2de2d97f3288512b9ea56f92e7452f8207eb5a0096500badf9dfd48f5e6/webrtcvad-2.0.10.tar.gz", hash = "sha256:f1bed2fb25b63fb7b1a55d64090c993c9c9167b28485ae0bcdd81cf6ede96aea", size = 66156, upload-time = "2017-01-07T23:05:18.732Z" }
+
+[[package]]
+name = "werkzeug"
+version = "3.1.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
+]
+
[[package]]
name = "wetext"
version = "0.1.2"