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""" -
-
-
{APP_NAME}
-
-
- """ - ) - ) - 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""" +
+
+
{APP_NAME}
+
+
+ """ + ) ) - 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"