From 30546d1cae16786dac49b7c510793b829176ae77 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Wed, 17 Jun 2026 18:45:12 +0200 Subject: [PATCH 01/26] refactor: event callbacks for extension system --- README.md | 60 +++ celune/__init__.py | 18 +- celune/celune.py | 141 ++++++- celune/dataclasses/events.py | 147 ++++++++ celune/extensions/base.py | 2 +- celune/extensions/events.py | 670 +++++++++++++++++++++++++++++++++ celune/extensions/manager.py | 238 +++++++++++- celune/pipeline.py | 12 +- celune/typing/celune.py | 11 +- celune/typing/events.py | 163 ++++++++ extensions/test.py | 21 +- tests/test_extension_events.py | 328 ++++++++++++++++ tests/test_package_api.py | 1 + tests/test_pipeline.py | 45 +++ 14 files changed, 1836 insertions(+), 21 deletions(-) create mode 100644 celune/dataclasses/events.py create mode 100644 celune/extensions/events.py create mode 100644 celune/typing/events.py create mode 100644 tests/test_extension_events.py diff --git a/README.md b/README.md index c9a3d1c..d02a35d 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,66 @@ 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 extensions still inherit from `CeluneExtension` and are still loaded through the extension manager from the `extensions` directory. Existing extensions continue to work without modification. + +Extensions may now also subscribe to typed engine lifecycle events with `@celune.subscribe(...)`. Decorated handlers are discovered automatically when the extension loads and are removed automatically when the extension unloads. + +For startup logic, prefer `@celune.subscribe("ready")`. The older `AUTOSTART = True` and `autostart()` hook remains available only as a deprecated compatibility path. + +Any subscribed handler can be opted in or out directly at the decorator site: + +```python +@celune.subscribe("ready", enabled=False) +def on_ready(event) -> None: + ... +``` + +```python +import celune +from celune import CeluneExtension + + +@celune.subscribe("ready") +def on_ready(event) -> None: + event.celune.log("Module-level ready handler.") + + +class DemoExtension(CeluneExtension): + EXTENSION_NAME = "Demo" + + @celune.subscribe("voice_changed") + def on_voice_changed(self, event) -> None: + self.log(f"Voice changed from {event.old_voice} to {event.new_voice}.") +``` + +Available event names: + +- `ready` +- `shutdown` +- `fatal` +- `error` +- `voice_changed` +- `state_changed` +- `generation_start` +- `generation_end` +- `generation_error` +- `audio_start` +- `audio_end` +- `character_changed` +- `character_loaded` +- `character_unloaded` + +Typed payload dataclasses live in `celune.dataclasses.events`, and callback aliases plus event-name literals live in `celune.typing.events`. + +Error handling guarantees: + +- Extension event callbacks are isolated from the core engine. +- Callback failures are logged as warnings. +- One failing callback does not stop the remaining callbacks. +- Non-required extensions never crash Celune through the event bus. + ## 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..48a5385 100644 --- a/celune/__init__.py +++ b/celune/__init__.py @@ -17,13 +17,14 @@ 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 if TYPE_CHECKING: from .celune import Celune from .extensions.base import CeluneContext, CeluneExtension + from .extensions.events import subscribe def _get_revision() -> str: @@ -60,7 +61,7 @@ def _caller_is_repl() -> bool: REVISION = _get_revision() -VERSION = "4.1.2" +VERSION = "4.2.0" if REVISION: _local = REVISION.rstrip("*") @@ -84,7 +85,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[..., object], +]: if name == "Celune": from .celune import Celune @@ -100,6 +106,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 +119,7 @@ def __getattr__( "CeluneContext", "CeluneExtension", "REVISION", + "subscribe", "__version__", "__tagline__", "__codename__", diff --git a/celune/celune.py b/celune/celune.py index 4f27476..6a8f8e9 100644 --- a/celune/celune.py +++ b/celune/celune.py @@ -35,9 +35,19 @@ 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 .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 @@ -56,6 +66,7 @@ ReleasableObject, VoiceLockStateCallback, ) +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, @@ -172,6 +183,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() @@ -298,6 +310,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 +376,65 @@ 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 _bundle_path_string(bundle: object) -> Optional[str]: + """Return one bundle path as a string when it is available.""" + path = getattr(bundle, "path", None) + if path is None: + return None + return str(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, @@ -632,10 +732,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 @@ -646,8 +754,16 @@ 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) + new_bundle_path = self._bundle_path_string(loader.bundle) self.voice_bundle_is_default = loader.bundle.path == default_bundle_path() self.current_character_persona = persona_metadata_from_manifest( loader.bundle.metadata @@ -668,6 +784,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: @@ -786,7 +909,7 @@ def setup_extensions(self) -> None: 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( @@ -859,6 +982,7 @@ def change_voice(self, voice: str) -> None: self.status_callback("Reloading") self.progress_callback(None, None) self.cur_state = "reloading" + active_voice = self.current_voice or voice try: with self._model_lock: @@ -888,6 +1012,15 @@ def change_voice(self, voice: str) -> None: self.loaded = True self.voice_changed_callback(voice) + if active_voice != voice: + self._emit_event( + "voice_changed", + VoiceChangedEvent( + celune=self, + old_voice=active_voice, + new_voice=voice, + ), + ) self.log(f"Voice {voice} loaded.") self.progress_callback(1, 1) self.cur_state = "idle" @@ -1036,9 +1169,6 @@ def load(self) -> bool: 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(): @@ -1051,6 +1181,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]: @@ -1497,6 +1629,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/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/extensions/base.py b/celune/extensions/base.py index 3d53dba..35750a8 100644 --- a/celune/extensions/base.py +++ b/celune/extensions/base.py @@ -36,7 +36,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: diff --git a/celune/extensions/events.py b/celune/extensions/events.py new file mode 100644 index 0000000..87c5a40 --- /dev/null +++ b/celune/extensions/events.py @@ -0,0 +1,670 @@ +# SPDX-License-Identifier: MIT +"""Celune's internal extension event dispatcher and decorators.""" + +from __future__ import annotations + +import threading +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable, Literal, Optional, TypeVar, cast, overload + +from ..typing.events import ( + AudioEndEventCallback, + AudioStartEventCallback, + CharacterChangedEventCallback, + CharacterLoadedEventCallback, + CharacterUnloadedEventCallback, + ErrorEventCallback, + EventName, + 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[..., object]) + + +@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: Callable[[object], None] + 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[Callable[[object], None]]] = defaultdict( + list + ) + self._owners: dict[tuple[EventName, Callable[[object], None]], 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: ... + + 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(Callable[[object], None], 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: ... + + 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(Callable[[object], None], 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: object) -> None: ... + + def emit(self, event_name: EventName, event: object) -> 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: Callable[[object], None]) -> 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[[object], None], + event_name: EventName, + enabled: bool, +) -> Callable[[object], 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: object, +) -> 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[..., object]: Decorator that records the event name on the callback. + """ + + def decorator(callback: _DecoratedCallback) -> _DecoratedCallback: + return cast( + _DecoratedCallback, + _store_subscription_metadata( + cast(Callable[[object], None], callback), + event_name, + enabled, + ), + ) + + return decorator diff --git a/celune/extensions/manager.py b/celune/extensions/manager.py index 7f0ef63..988e4d2 100644 --- a/celune/extensions/manager.py +++ b/celune/extensions/manager.py @@ -7,18 +7,47 @@ import traceback import importlib.util from pathlib import Path +from dataclasses import dataclass +from types import ModuleType +from typing import Callable, Optional, cast +from ..dataclasses.events import ReadyEvent +from ..typing.events import EventName from ..utils import format_error from .base import CeluneContext, CeluneExtension +from .events import ( + EventDispatcher, + RegisteredEventHandler, + iter_subscriptions, +) from ..exceptions import InvalidExtensionError, ExtensionAlreadyRegisteredError +@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,11 +80,50 @@ 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) + + def emit(self, event_name: EventName, event: object) -> 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.", @@ -63,10 +131,17 @@ def autostart_all(self) -> None: ) return + self.context.log( + ( + "[Core] autostart_all() is deprecated. " + "Use @celune.subscribe('ready') instead." + ), + "warning", + ) 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 deprecated autostart for: {name}") def runner(e=ext, n=name): try: @@ -170,6 +245,11 @@ def autoload(self, folder: str = "extensions") -> None: 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): @@ -196,3 +276,153 @@ def autoload(self, folder: str = "extensions") -> None: f"[Core] {file_path.name} is not a Celune extension, skipping", "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=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 + + self.context.log( + ( + f"[Core] Extension '{extension.name}' uses deprecated autostart() " + "behavior. Please migrate to @celune.subscribe('ready')." + ), + "warning", + ) + + def legacy_ready_callback( + event: ReadyEvent, ext: CeluneExtension = extension + ) -> None: + del 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 + + 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: object, + ) -> RegisteredEventHandler: + """Register one discovered callback against the dispatcher.""" + if not callable(callback): + raise TypeError("event callback is not callable") + + typed_callback = cast(Callable[[object], 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/pipeline.py b/celune/pipeline.py index ae68289..1f78c19 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -2066,8 +2066,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 @@ -2189,9 +2188,12 @@ 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 diff --git a/celune/typing/celune.py b/celune/typing/celune.py index 0ea2042..d81a80e 100644 --- a/celune/typing/celune.py +++ b/celune/typing/celune.py @@ -245,7 +245,6 @@ class CeluneStateAccessors: sleeping: bool _last_flavor: Optional[str] _ready_announced: bool - cur_state: str is_in_tutorial: bool extension_manager: Optional["CeluneExtensionManager"] glow: "AudioRGBGlow" @@ -265,3 +264,13 @@ 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.""" + raise NotImplementedError("typing surface only") + + @cur_state.setter + def cur_state(self, value: str) -> None: + """Store the current runtime-state label.""" + raise NotImplementedError("typing surface only") diff --git a/celune/typing/events.py b/celune/typing/events.py new file mode 100644 index 0000000..79cbb4c --- /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, +) + + +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 + + +ReadyEventCallback = Callable[[ReadyEvent], None] +ShutdownEventCallback = Callable[[ShutdownEvent], None] +FatalEventCallback = Callable[[FatalEvent], None] +ErrorEventCallback = Callable[[ErrorEvent], None] +VoiceChangedEventCallback = Callable[[VoiceChangedEvent], None] +StateChangedEventCallback = Callable[[StateChangedEvent], None] +GenerationStartEventCallback = Callable[[GenerationStartEvent], None] +GenerationEndEventCallback = Callable[[GenerationEndEvent], None] +GenerationErrorEventCallback = Callable[[GenerationErrorEvent], None] +AudioStartEventCallback = Callable[[AudioStartEvent], None] +AudioEndEventCallback = Callable[[AudioEndEvent], None] +CharacterChangedEventCallback = Callable[[CharacterChangedEvent], None] +CharacterLoadedEventCallback = Callable[[CharacterLoadedEvent], None] +CharacterUnloadedEventCallback = Callable[[CharacterUnloadedEvent], None] + +EventPayload = Union[ + ReadyEvent, + ShutdownEvent, + FatalEvent, + ErrorEvent, + VoiceChangedEvent, + StateChangedEvent, + GenerationStartEvent, + GenerationEndEvent, + GenerationErrorEvent, + AudioStartEvent, + AudioEndEvent, + CharacterChangedEvent, + CharacterLoadedEvent, + CharacterUnloadedEvent, +] + +EventCallback = Union[ + ReadyEventCallback, + ShutdownEventCallback, + FatalEventCallback, + ErrorEventCallback, + VoiceChangedEventCallback, + StateChangedEventCallback, + GenerationStartEventCallback, + GenerationEndEventCallback, + GenerationErrorEventCallback, + AudioStartEventCallback, + AudioEndEventCallback, + CharacterChangedEventCallback, + CharacterLoadedEventCallback, + CharacterUnloadedEventCallback, +] diff --git a/extensions/test.py b/extensions/test.py index 67d09e1..7db4265 100644 --- a/extensions/test.py +++ b/extensions/test.py @@ -4,6 +4,7 @@ import time from pathlib import Path +import celune from celune import CeluneExtension @@ -11,10 +12,15 @@ 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) -> None: + """Demonstrate extension behavior when Celune becomes ready. + + Args: + event: Ready event emitted after Celune finishes startup. + """ + del event self.log("Log test") time.sleep(1) # due to threading, this does not block self.status("Status test") @@ -40,6 +46,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) -> 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/tests/test_extension_events.py b/tests/test_extension_events.py new file mode 100644 index 0000000..3a15407 --- /dev/null +++ b/tests/test_extension_events.py @@ -0,0 +1,328 @@ +# SPDX-License-Identifier: MIT +"""Tests for Celune's extension event subsystem.""" + +import sys +import tempfile +import textwrap +from pathlib import Path +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 .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] = [] + + def first(_event: object) -> None: + calls.append("first") + + def second(_event: object) -> None: + calls.append("second") + + self.dispatcher.subscribe("ready", first, owner_name="first") + self.dispatcher.subscribe("ready", second, owner_name="second") + self.dispatcher.emit("ready", object()) + self.assertEqual(calls, ["first", "second"]) + + self.dispatcher.unsubscribe("ready", first) + self.dispatcher.emit("ready", object()) + 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] = [] + + def broken(_event: object) -> None: + calls.append("broken") + raise RuntimeError("boom") + + def healthy(_event: object) -> None: + calls.append("healthy") + + self.dispatcher.subscribe("ready", broken, owner_name="broken") + self.dispatcher.subscribe("ready", healthy, owner_name="healthy") + + self.dispatcher.emit("ready", object()) + + 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.""" + 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.""" + received.append("ready") + + @subscribe("voice_changed", enabled=True) + def on_voice_changed(self, _event: VoiceChangedEvent) -> None: + """React to a mock voice change event.""" + 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_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_pipeline.py b/tests/test_pipeline.py index 26bb04a..a6f4bac 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1926,6 +1926,51 @@ def generate_stream( self.assertEqual(queued_lengths, [144000]) self.assertEqual(engine.smart_buffer_target_seconds, float("inf")) + 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. From 1c872edd817b26a131d24052c0c4b499eb8abc3e Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Thu, 18 Jun 2026 16:57:56 +0200 Subject: [PATCH 02/26] feat: base VC support --- celune/celune.py | 283 ++++++++++++++++++++------ celune/dataclasses/__init__.py | 9 + celune/dataclasses/celune.py | 13 ++ celune/dataclasses/pipeline.py | 31 +++ celune/pipeline.py | 47 +++++ celune/typing/celune.py | 20 +- celune/typing/pipeline.py | 12 +- celune/vc_backends/__init__.py | 63 ++++++ celune/vc_backends/base.py | 34 ++++ celune/vc_backends/passthrough.py | 30 +++ tests/support.py | 27 +++ tests/test_backends_and_extensions.py | 35 ++++ tests/test_celune_core.py | 162 ++++++++++++++- tests/test_pipeline.py | 87 +++++++- 14 files changed, 786 insertions(+), 67 deletions(-) create mode 100644 celune/vc_backends/__init__.py create mode 100644 celune/vc_backends/base.py create mode 100644 celune/vc_backends/passthrough.py diff --git a/celune/celune.py b/celune/celune.py index db47a6e..c512cbf 100644 --- a/celune/celune.py +++ b/celune/celune.py @@ -31,6 +31,7 @@ CeluneRuntimeState, CeluneVoiceState, ) +from .dataclasses.pipeline import AudioInputRequest from .dataclasses.properties import ( bind_constant_properties, bind_forwarded_properties, @@ -53,6 +54,7 @@ from .paths import project_root from .runtime import log_runtime_banner, validate_runtime from .backends import CeluneBackend, resolve_backend +from .vc_backends import 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 @@ -102,6 +104,7 @@ queue_sfx_audio, playback_worker, play as play_pipeline, + handle_audio_input, queue_speech, release_pipeline, say as say_pipeline, @@ -128,6 +131,24 @@ def _config_int(value: JSONSerializable, default: int) -> int: raise TypeError("config value cannot be converted to int") +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 _release_loaded_object(value: ReleasableObject) -> None: """Best-effort release hook for one loaded runtime object.""" close = getattr(value, "close", None) @@ -151,6 +172,8 @@ def __init__( self, config: Config, tts_backend: Optional[Union[str, CeluneBackend, type[CeluneBackend]]] = None, + vc_backend: Optional[Union[str, CeluneVCBackend, type[CeluneVCBackend]]] = 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 @@ -195,6 +218,7 @@ def __init__( self._pipeline_state.playback_done.set() self.config = config + self.input_mode = _resolve_input_mode(config, input_mode) select_voice_bundle(_config_str(config_value(config, "voice_bundle"))) preset = resolve_vram_preset(config) @@ -259,6 +283,40 @@ def __init__( 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: + if not isinstance(vc_backend, CeluneVCBackend): + self._vc_backend_spec = vc_backend + self.vc_backend = resolve_vc_backend( + vc_backend, + log=self.log_callback, + ) + 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: @@ -450,6 +508,16 @@ def _validate_backend_against_preset( f"'{backend.clone_model_id}' for VRAM tier '{preset.tier}'" ) + def _is_voice_conversion_mode(self) -> bool: + """Return whether Celune is currently running in voice-conversion mode.""" + return self.input_mode == "voice_conversion" + + 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.""" @@ -500,6 +568,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() @@ -525,6 +595,18 @@ 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, + ) + self.voice_conversion_backend = self.vc_backend.name + return True + 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: @@ -658,21 +740,32 @@ def wake_from_sleep(self) -> bool: 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() @@ -1004,30 +1097,38 @@ def change_voice(self, voice: str) -> None: 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.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.current_voice = voice - self.loaded = True + 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) if active_voice != voice: @@ -1076,7 +1177,7 @@ def load(self) -> bool: disable_progress_bars() hf_logging.set_verbosity_error() - log_runtime_banner(self.log, self.backend.name) + log_runtime_banner(self.log, self._active_runtime_backend_name()) if not self.load_available_voices(): self.cur_state = "error" self.log("No voices were loaded.", "error") @@ -1111,23 +1212,39 @@ def load(self) -> bool: ) 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("Voice conversion backend is not configured.", "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("Voice conversion backend is not configured") + 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("Voice conversion backend is ready.") + else: + self.backend.preload_models() + + 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 if self.vision is not None: self.log("Initializing Persona...") @@ -1145,15 +1262,19 @@ def load(self) -> bool: else: self.log("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( @@ -1163,7 +1284,7 @@ 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() @@ -1171,7 +1292,11 @@ def load(self) -> bool: self.log_dev("Could not play the error signal.", "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() @@ -1539,6 +1664,11 @@ def think( Returns: bool: ``True`` if Celune processed this smart request, otherwise ``False``. """ + if self.input_mode != "text_to_speech": + self.log("Text input is unavailable in voice conversion mode.", "warning") + self.error_callback("Text input is unavailable in voice conversion mode") + return False + if self.is_in_tutorial: self.log("Speech input is disabled during the tutorial.", "warning") return False @@ -1596,6 +1726,12 @@ def say( Returns: bool: ``True`` when the text was queued successfully, otherwise ``False``. """ + if self.input_mode != "text_to_speech": + self.log("Text input is unavailable in voice conversion mode.", "warning") + self.error_callback("Text input is unavailable in voice conversion mode") + 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]: @@ -1614,6 +1750,31 @@ 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", + ) -> 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. + + 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, + ), + ) + def play(self, sound_path: str, keep: bool = False, volume: float = 1.0) -> bool: """Play a sound via Celune's pipeline. diff --git a/celune/dataclasses/__init__.py b/celune/dataclasses/__init__.py index 4359d32..fd8e199 100644 --- a/celune/dataclasses/__init__.py +++ b/celune/dataclasses/__init__.py @@ -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..62ed39d 100644 --- a/celune/dataclasses/celune.py +++ b/celune/dataclasses/celune.py @@ -12,6 +12,7 @@ from transformers.tokenization_utils_base import PreTrainedTokenizerBase from ..backends import CeluneBackend +from ..vc_backends import CeluneVCBackend from ..cevoice import CEVoicePersona from ..chroma import AudioRGBGlow from ..config import Config @@ -57,6 +58,10 @@ class CeluneBackendState: backend_kwargs: dict[str, JSONSerializable] = field(default_factory=dict) backend: Optional[CeluneBackend] = None tts_backend: str = "" + vc_backend_spec: Optional[Union[str, type[CeluneVCBackend]]] = None + vc_backend: Optional[CeluneVCBackend] = None + voice_conversion_backend: str = "" + input_mode: str = "text_to_speech" chunk_size: int = 0 language: str = "Auto" dev: bool = False @@ -174,6 +179,14 @@ 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("input_mode", "_backend_state", "input_mode"), ForwardedPropertySpec("chunk_size", "_backend_state", "chunk_size"), ForwardedPropertySpec("language", "_backend_state", "language"), ForwardedPropertySpec("dev", "_backend_state", "dev"), diff --git a/celune/dataclasses/pipeline.py b/celune/dataclasses/pipeline.py index 193a5b4..7971a4d 100644 --- a/celune/dataclasses/pipeline.py +++ b/celune/dataclasses/pipeline.py @@ -5,6 +5,7 @@ import queue import time from dataclasses import dataclass +from pathlib import Path from typing import Optional, Union import numpy as np @@ -27,6 +28,36 @@ 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" + + +@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" + + +@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.""" diff --git a/celune/pipeline.py b/celune/pipeline.py index 982d1a2..7f7cc80 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -31,10 +31,12 @@ from . import __version__ from .dataclasses.pipeline import ( + AudioInputRequest, PlaybackChunk, PlaybackSourceDone, SpeechRequest, SpeechTiming, + VoiceConversionRequest, ) from .exceptions import NotAvailableError from .persona.memory import PersonaMemoryStore @@ -106,6 +108,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 @@ -1431,6 +1435,49 @@ 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": + backend = getattr(engine, "vc_backend", None) + if backend is None: + engine.log("Voice conversion backend is not configured.", "warning") + engine.error_callback("Voice conversion backend is not configured") + engine.progress_callback(0, 1) + return False + + output = backend.convert( + VoiceConversionRequest( + source_audio=audio, + sample_rate=request.sample_rate, + target_voice=getattr(engine, "current_voice", None), + target_character=getattr(engine, "current_character", None), + target_references=(), + label=request.label, + ) + ) + return queue_sfx_audio( + engine, + output.audio, + output.sample_rate, + output.label, + ) + + 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 queue_speech( engine: Celune, text: str, diff --git a/celune/typing/celune.py b/celune/typing/celune.py index d81a80e..d74312f 100644 --- a/celune/typing/celune.py +++ b/celune/typing/celune.py @@ -26,6 +26,7 @@ from ..dsp import StreamingPedalboardReverb from ..extensions.manager import CeluneExtensionManager from ..persona.impl import PersonaClient + from ..vc_backends import CeluneVCBackend class SupportsClose(Protocol): @@ -192,6 +193,10 @@ class CeluneStateAccessors: _backend_kwargs: dict[str, JSONSerializable] backend: "CeluneBackend" tts_backend: str + _vc_backend_spec: Optional[Union[str, type["CeluneVCBackend"]]] + vc_backend: Optional["CeluneVCBackend"] + voice_conversion_backend: str + input_mode: str chunk_size: int language: str dev: bool @@ -267,10 +272,21 @@ class CeluneStateAccessors: @property def cur_state(self) -> str: - """Return the current runtime-state label.""" + """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.""" + """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/pipeline.py b/celune/typing/pipeline.py index 680f2c2..ec3d093 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] +AudioInputItem = AudioInputRequest +AudioOutputItem = AudioOutput +VoiceConversionInputItem = VoiceConversionRequest AudioChunk = PlaybackChunk AudioQueueItem = Union[PlaybackChunk, PlaybackSourceDone, PipelineStates] diff --git a/celune/vc_backends/__init__.py b/celune/vc_backends/__init__.py new file mode 100644 index 0000000..422b4d3 --- /dev/null +++ b/celune/vc_backends/__init__.py @@ -0,0 +1,63 @@ +# 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.vc_backends.passthrough", "CelunePassthroughVCBackend"), +} + + +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/vc_backends/base.py b/celune/vc_backends/base.py new file mode 100644 index 0000000..4b1b714 --- /dev/null +++ b/celune/vc_backends/base.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MIT +"""Unified voice-conversion backend abstractions for Celune.""" + +from abc import ABC, abstractmethod +from typing import Callable + +from ..dataclasses.pipeline import AudioOutput, VoiceConversionRequest + +__all__ = ["CeluneVCBackend"] + + +class CeluneVCBackend(ABC): + """Base class for Celune voice-conversion backends.""" + + name: str = "unknown" + + 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/vc_backends/passthrough.py b/celune/vc_backends/passthrough.py new file mode 100644 index 0000000..0f2c06f --- /dev/null +++ b/celune/vc_backends/passthrough.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: MIT +"""Passthrough voice-conversion backend for plumbing and tests.""" + +import numpy as np + +from ..dataclasses.pipeline import AudioOutput, VoiceConversionRequest +from .base import CeluneVCBackend + +__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/tests/support.py b/tests/support.py index a2ab7df..68018a9 100644 --- a/tests/support.py +++ b/tests/support.py @@ -19,7 +19,9 @@ from celune.utils import discard from celune.backends.base import CeluneBackend +from celune.vc_backends.base import CeluneVCBackend from celune.constants import JSONSerializable, PipelineStates +from celune.dataclasses.pipeline import AudioOutput, VoiceConversionRequest if TYPE_CHECKING: from celune.celune import Celune @@ -88,6 +90,27 @@ def generate_stream( 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 +237,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 diff --git a/tests/test_backends_and_extensions.py b/tests/test_backends_and_extensions.py index df6cf93..e8fb941 100644 --- a/tests/test_backends_and_extensions.py +++ b/tests/test_backends_and_extensions.py @@ -19,8 +19,11 @@ from celune.utils import discard from celune.backends import resolve_backend +from celune.vc_backends import resolve_vc_backend +from celune.vc_backends.passthrough import CelunePassthroughVCBackend 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, @@ -28,6 +31,7 @@ ) from .support import ( FakeBackend, + FakeVCBackend, make_voice_loader, mock_dotstts_backend, mock_mini_backend, @@ -114,6 +118,37 @@ def test_resolve_backend_accepts_instance_type_and_rejects_unknown(self) -> None 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_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_backend_accepts_mini_backend_name(self) -> None: """Verify the Pocket TTS backend resolves through the backend registry.""" diff --git a/tests/test_celune_core.py b/tests/test_celune_core.py index 5187f0f..4ad6c35 100644 --- a/tests/test_celune_core.py +++ b/tests/test_celune_core.py @@ -7,6 +7,7 @@ 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 @@ -14,11 +15,11 @@ from celune.config import Config from celune.backends.qwen3 import Qwen3 from celune.constants import JSONSerializable -from celune.pipeline import play_signal, release_pipeline +from celune.pipeline import 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 .support import FakeBackend, FakeGlow, FakeVCBackend class CeluneCoreTests(TestCase): @@ -223,6 +224,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 +287,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,6 +541,95 @@ 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_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({}) + audio = np.ones((32, 2), dtype=np.float32) + + with ( + mock.patch("celune.celune.handle_audio_input", return_value=True) as handle, + mock.patch("celune.celune.say_pipeline", return_value=True) as say_pipeline, + ): + 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)) + say_pipeline.assert_called_once_with( + celune, + "hello", + save=True, + display_text=None, + ) + + 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) + + 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) + + handle.assert_called_once() + queue_sfx.assert_called_once() + say_pipeline.assert_not_called() + + 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. diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index a34ebfa..c3a162a 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 from .test_persona_memory import StubEmbeddingMemoryStore @@ -214,6 +215,90 @@ 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") + + with 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) + 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_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() From 23ba74af9e222531f90ff54f2df62f6fecd5813f Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Sat, 20 Jun 2026 18:21:03 +0200 Subject: [PATCH 03/26] feat: backend reloading in place, move cache directory to be detachable --- AGENTS.md | 12 +- celune/__init__.py | 3 + celune/api.py | 241 +++++++- celune/backends/base.py | 97 +++- celune/backends/dotstts.py | 5 +- celune/backends/mini.py | 19 +- celune/backends/qwen3.py | 5 +- celune/backends/voxcpm2.py | 24 +- celune/celune.py | 768 +++++++++++++++++++++++++- celune/cevoice.py | 54 ++ celune/dataclasses/celune.py | 2 + celune/dataclasses/extensions.py | 48 ++ celune/dataclasses/pipeline.py | 1 + celune/extensions/base.py | 25 +- celune/paths.py | 77 +++ celune/persona/memory.py | 4 +- celune/persona/prompts.py | 34 +- celune/pipeline.py | 100 +++- celune/typing/celune.py | 1 + celune/ui/app.py | 11 +- celune/ui/commands.py | 59 ++ requirements.txt | 1 + tests/test_api_audio.py | 102 +++- tests/test_api_webui.py | 65 +++ tests/test_backends_and_extensions.py | 65 ++- tests/test_celune_core.py | 714 +++++++++++++++++++++++- tests/test_extension_events.py | 18 +- tests/test_persona_memory.py | 22 +- tests/test_pipeline.py | 104 +++- tests/test_runtime_and_ui_commands.py | 123 ++++- tests/test_runtime_paths.py | 121 +++- 31 files changed, 2804 insertions(+), 121 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a2faa1d..317a512 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,15 @@ Returns: with proper documentation, while preserving the docstring format. -This process may leave some formatting inaccuracies, run `uv run ruff format .` again after completing docstrings. +If this process updates typing or dataclass related docstrings, remove the placeholders instead of completing them. -If CI fails or times out, report the actual failure clearly. Do not claim success. +This process may leave some formatting inaccuracies, run `uv run ruff format .` again after completing docstrings. ## 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/celune/__init__.py b/celune/__init__.py index 48a5385..efae663 100644 --- a/celune/__init__.py +++ b/celune/__init__.py @@ -20,6 +20,9 @@ from typing import TYPE_CHECKING, Callable, Union from .constants import APP_NAME +from .paths import configure_huggingface_cache_environment + +configure_huggingface_cache_environment() if TYPE_CHECKING: from .celune import Celune diff --git a/celune/api.py b/celune/api.py index a930f25..bc4c5f2 100644 --- a/celune/api.py +++ b/celune/api.py @@ -73,6 +73,7 @@ WebUiUpdate = dict[str, JSONSerializable] WebUiAudioValue = Optional[tuple[int, npt.NDArray[np.float32]]] +WebUiInputAudioValue = Optional[tuple[int, npt.NDArray[np.float32]]] class _WebUiUnset: @@ -860,13 +861,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 +920,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", } @@ -1194,6 +1198,25 @@ 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 _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": "I am not currently able to do this.", + }, + ) + + def _webui_speak( content: str, ) -> Iterator[ @@ -1278,6 +1301,107 @@ def _webui_speak( yield snapshot[0], None, *snapshot[1:] +def _webui_convert_audio( + source_audio: WebUiInputAudioValue, +) -> 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( + "Upload or record audio before starting voice conversion.", + "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( + "Audio conversion is only available in voice conversion 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, + ) + + sample_rate, audio = source_audio + api_log("CONVERT(WEBUI)", "uploaded audio") + try: + output = celune.convert_audio(audio, sample_rate, label="browser audio input") + except Exception as e: + _append_webui_log( + f"[WEBUI 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("I can't convert that 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, + ) + + converted_audio = (output.sample_rate, np.asarray(output.audio, dtype=np.float32)) + 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 @@ -1363,6 +1487,17 @@ def _build_webui() -> gr.Blocks: min_width=0, interactive=False, ) + source_audio = gr.Audio( + value=None, + type="numpy", + sources=["upload", "microphone"], + label="Source audio", + elem_id="celune-source-audio", + ) + convert_button = gr.Button( + value="Convert Audio", + elem_id="celune-convert-audio", + ) with gr.Row(elem_id="celune-footer"): status = gr.HTML(_webui_status_html(), elem_id="celune-status") resources = gr.HTML( @@ -1429,6 +1564,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], + outputs=[ + source_audio, + audio, + logs, + status, + resources, + voice_button, + send_button, + ], + show_progress="hidden", + ) return demo @@ -1711,14 +1860,14 @@ async def sfx( ) 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": "I don't understand your input.", }, ) @@ -1741,6 +1890,80 @@ def chunks() -> Iterator[bytes]: ) +@api.post("/v1/convert", response_model=None) +async def convert_audio( + file: UploadFile = File(...), +) -> Union[StreamingResponse, JSONResponse]: + """Convert an uploaded source audio file through the active VC backend. + + Args: + file: The uploaded source audio file to convert. + + 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": "That source audio is too large for me to convert.", + }, + ) + + try: + audio, sample_rate = _decode_uploaded_audio(data) + except Exception: + return JSONResponse( + status_code=400, + content={ + "error": "invalid_audio", + "message": "I don't understand your input.", + }, + ) + + try: + output = celune.convert_audio(audio, sample_rate, label=filename) + except Exception: + return JSONResponse( + status_code=500, + content={ + "error": "request_failed", + "message": "I couldn't convert that.", + }, + ) + + if output is None: + return JSONResponse( + status_code=409, + content={ + "error": "not_ready", + "message": "I can't convert that.", + }, + ) + + def chunks() -> Iterator[bytes]: + yield _flac_bytes( + np.asarray(output.audio, dtype=np.float32), + sample_rate=output.sample_rate, + ) + + return StreamingResponse( + chunks(), + media_type="audio/flac", + headers=stream_headers(output.sample_rate), + ) + + api = gr.mount_gradio_app( api, _build_webui(), diff --git a/celune/backends/base.py b/celune/backends/base.py index 9b3c6aa..30a6402 100644 --- a/celune/backends/base.py +++ b/celune/backends/base.py @@ -9,6 +9,7 @@ import threading import contextlib import hashlib +import unittest.mock from pathlib import Path from abc import ABC, abstractmethod from collections.abc import Iterator, Generator @@ -19,13 +20,12 @@ 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 ..paths import huggingface_hub_cache_dir, temp_data_dir from ..typing.backends import BackendModel, ModelT __all__ = [ @@ -38,6 +38,27 @@ _HF_HUB_OFFLINE_LOCK = threading.Lock() _MAX_REFERENCE_SECONDS = 10.0 +_RUNTIME_PRIMITIVE_TYPES = (str, bytes, bytearray, int, float, bool, type(None)) + + +def _call_runtime_hook_if_present(value: object, name: str) -> bool: + """Call one release hook only when it already exists on the runtime object.""" + with contextlib.suppress(TypeError): + existing = vars(value).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 +73,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 +124,70 @@ 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: object, 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: object, seen: set[int]) -> None: + """Recursively release nested runtime members held on one object instance.""" + with contextlib.suppress(TypeError): + members = list(vars(value).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: object, 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.""" @@ -318,6 +406,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/dotstts.py index 30c5371..b80db54 100644 --- a/celune/backends/dotstts.py +++ b/celune/backends/dotstts.py @@ -13,7 +13,6 @@ from dots_tts.runtime import DotsTtsRuntime from ..utils import custom_assert -from ..exceptions import BackendError from ..cevoice import default_loader, CEVoiceLoader from .base import CeluneBackend, cached_hf_snapshot_path, local_hf_offline_mode @@ -94,7 +93,7 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]: loader = default_loader() custom_assert( loader is not None, - BackendError( + FileNotFoundError( "backend 'dotstts' requires a compatible CEVOICE/CECHAR package " "with at least one valid voice identifier" ), @@ -114,7 +113,7 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]: ) custom_assert( bool(voice_names), - BackendError( + FileNotFoundError( "backend 'dotstts' requires a compatible CEVOICE/CECHAR package " "with at least one valid voice identifier" ), diff --git a/celune/backends/mini.py b/celune/backends/mini.py index d87da1a..fce98e5 100644 --- a/celune/backends/mini.py +++ b/celune/backends/mini.py @@ -15,7 +15,7 @@ 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 .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" ) diff --git a/celune/backends/qwen3.py b/celune/backends/qwen3.py index 346b7d3..02c1818 100644 --- a/celune/backends/qwen3.py +++ b/celune/backends/qwen3.py @@ -10,7 +10,6 @@ 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 .base import CeluneBackend, cached_hf_snapshot_path, local_hf_offline_mode @@ -63,7 +62,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 +82,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" ), diff --git a/celune/backends/voxcpm2.py b/celune/backends/voxcpm2.py index 36a7ca9..4010262 100644 --- a/celune/backends/voxcpm2.py +++ b/celune/backends/voxcpm2.py @@ -6,7 +6,6 @@ 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 @@ -14,7 +13,6 @@ 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 .base import CeluneBackend, cached_hf_snapshot_path, local_hf_offline_mode @@ -87,7 +85,7 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]: loader = default_loader() custom_assert( loader is not None, - BackendError( + FileNotFoundError( "backend 'voxcpm2' requires a compatible CEVOICE/CECHAR package " "with at least one valid voice identifier" ), @@ -107,7 +105,7 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]: ) custom_assert( bool(voice_names), - BackendError( + FileNotFoundError( "backend 'voxcpm2' requires a compatible CEVOICE/CECHAR package " "with at least one valid voice identifier" ), @@ -195,8 +193,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(): diff --git a/celune/celune.py b/celune/celune.py index c512cbf..04fd419 100644 --- a/celune/celune.py +++ b/celune/celune.py @@ -3,12 +3,14 @@ import os import gc +import shutil import time import queue import threading import contextlib from pathlib import Path from typing import Optional, Callable, Union, cast +from dataclasses import dataclass import torch import numpy as np @@ -31,7 +33,7 @@ CeluneRuntimeState, CeluneVoiceState, ) -from .dataclasses.pipeline import AudioInputRequest +from .dataclasses.pipeline import AudioInputRequest, AudioOutput from .dataclasses.properties import ( bind_constant_properties, bind_forwarded_properties, @@ -51,9 +53,9 @@ 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 import BACKENDS, CeluneBackend, resolve_backend from .vc_backends import CeluneVCBackend, resolve_vc_backend from .exceptions import NotAvailableError, WarmupError, BackendError from .modeling import normalizer_device, load_normalizer_components @@ -87,11 +89,15 @@ persona_quantization, ) from .cevoice import ( + CEVoicePersona, + active_bundle_path, announce_default_bundle, 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 ( @@ -105,6 +111,7 @@ playback_worker, play as play_pipeline, handle_audio_input, + convert_audio_input, queue_speech, release_pipeline, say as say_pipeline, @@ -163,11 +170,40 @@ 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] + tts_backend: str + model: Optional[object] + 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, @@ -280,6 +316,10 @@ 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 @@ -438,6 +478,56 @@ 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. + + Args: + path: The temp file or directory to classify. + + Returns: + bool: ``True`` when the path matches Celune's disposable temp artifacts. + """ + 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. + + Args: + temp_dir: The Celune temp directory to scan. + """ + 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(f"{APP_NAME} found a residual temporary item.", "warning") + else: + self.log( + f"{APP_NAME} found {trailing_files} residual temporary items.", + "warning", + ) + self.log("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: object) -> Optional[str]: """Return one bundle path as a string when it is available.""" @@ -607,6 +697,417 @@ def _recreate_vc_backend(self) -> bool: self.voice_conversion_backend = self.vc_backend.name return True + 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 TTS 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), + tts_backend=self.tts_backend, + model=cast(Optional[object], 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.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 TTS 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.tts_backend = snapshot.tts_backend + 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[object], restored_model) + snapshot.model_name = restored_model_name + + 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), + loader.bundle.path == default_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: Union[str, CeluneBackend, type[CeluneBackend]], + preferred_voice: Optional[str] = None, + ) -> bool: + """Synchronously switch to a new TTS backend with rollback on failure.""" + snapshot: Optional[Celune._ReloadSnapshot] = None + candidate_kwargs: dict[str, JSONSerializable] = {} + candidate_backend: Optional[CeluneBackend] = None + candidate_model: Optional[PreTrainedModel] = None + requested_name = ( + backend_spec.name + if isinstance(backend_spec, CeluneBackend) + else str(backend_spec) + ) + + try: + snapshot = self._capture_reload_snapshot() + preset = resolve_vram_preset(self.config) + candidate_kwargs = self._backend_reload_kwargs(backend_spec) + self.log(f"{APP_NAME} is switching to {requested_name}, please wait...") + self._ready_announced = False + self.status_callback("Reloading backend") + self.progress_callback(None, None) + self.cur_state = "reloading" + + if self._is_voice_conversion_mode(): + raise BackendError("TTS backends are not available in VC mode") + + candidate_backend = resolve_backend( + 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") + + previous_backend = self.backend + previous_voice = snapshot.current_voice + 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.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 = ( + backend_spec + if not isinstance(backend_spec, CeluneBackend) + else type(candidate_backend) + ) + self._backend_kwargs = dict(candidate_kwargs) + self._release_reload_snapshot(snapshot) + if self.use_normalization: + self._unload_normalizer_components() + self.load_normalizer() + self.loaded = True + 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(f"Switched backend to {requested_name}.") + self.progress_callback(1, 1) + self.cur_state = "idle" + self.status_callback("Idle") + return True + except Exception as e: + self.log( + f"[RELOAD ERROR] {format_error(e, self.dev)}", + "error", + ) + self.status_callback("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 ( + snapshot is not None + and snapshot.backend.model is None + and snapshot.loaded + ): + self._rebuild_reload_snapshot_runtime(snapshot) + self._restore_reload_snapshot(snapshot) + else: + self.cur_state = "idle" + self._last_warmup_error = None + self.status_callback("Idle") + self.progress_callback(1, 1) + self.log( + "Could not load this backend. The previous backend was restored.", + "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(f"{APP_NAME} is reloading the character, please stand by...") + self._ready_announced = False + self.status_callback("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( + f"Switched to character: {bundle if bundle is not None else previous_bundle.name}" + ) + self.progress_callback(1, 1) + self.cur_state = "idle" + self.status_callback("Idle") + return True + except Exception as e: + self.log( + f"[RELOAD 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("Idle") + self.progress_callback(1, 1) + self.log( + "Could not switch to this character. The previous character was restored.", + "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: @@ -954,10 +1455,195 @@ 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("Timed out while switching voice.", "warning") return False return self.loaded and self.current_voice == name + def set_backend( + self, + backend_spec: Union[str, CeluneBackend, type[CeluneBackend]], + ) -> bool: + """Request a hot reload into another TTS 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("A backend or character reload is already in progress.", "warning") + return False + if isinstance(backend_spec, str): + normalized_backend = backend_spec.strip().lower() + if normalized_backend not in BACKENDS: + self.log( + f"Unknown backend: {backend_spec} " + f"(available: {', '.join(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: Union[str, CeluneBackend, type[CeluneBackend]], + 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("Timed out while switching backends.", "warning") + return False + + target_name = ( + backend_spec.name + if isinstance(backend_spec, CeluneBackend) + else getattr(backend_spec, "name", str(backend_spec)) + ) + return self.loaded and self.tts_backend == 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("A backend or character reload is already in progress.", "warning") + return False + + if bundle is not None: + resolved_bundle = resolve_bundle_path(bundle) + if not resolved_bundle.exists(): + self.log(f"Voice pack not found: {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("Timed out while switching characters.", "warning") + return False + return self.loaded and active_bundle_path() == resolve_bundle_path(bundle) + + @contextlib.contextmanager + def with_backend( + self, + backend_spec: Union[str, CeluneBackend, type[CeluneBackend]], + 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_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 @@ -1001,6 +1687,8 @@ 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, @@ -1178,6 +1866,7 @@ def load(self) -> bool: hf_logging.set_verbosity_error() log_runtime_banner(self.log, self._active_runtime_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") @@ -1215,18 +1904,18 @@ def load(self) -> bool: if self._is_voice_conversion_mode(): if self.vc_backend is None: self.cur_state = "error" - self.log("Voice conversion backend is not configured.", "error") + self.log("No voice conversion backend is available.", "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("Voice conversion backend is not configured") + self.error_callback("No valid voice conversion backend") return False self.vc_backend.preload_models() self.model = None self.model_name = "" - self.log("Voice conversion backend is ready.") + self.log("Ready to accept voice conversions.") else: self.backend.preload_models() @@ -1480,13 +2169,22 @@ def _worker(): f"{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.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", @@ -1503,16 +2201,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 @@ -1523,15 +2221,15 @@ 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.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("Could not play the error signal.", "warning") + self.error_callback(f"{APP_NAME} could not warm up") return False # as of CeluneNorm 2.0, normalization ACTUALLY works with long inputs @@ -1775,6 +2473,40 @@ def submit_audio( ), ) + def convert_audio( + self, + audio: npt.NDArray[np.float32], + sample_rate: int, + label: str = "audio input", + ) -> 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. + + Returns: + Optional[AudioOutput]: The converted audio output, or ``None`` when voice conversion is unavailable. + """ + if not self._is_voice_conversion_mode(): + self.log( + "Audio conversion is unavailable outside voice conversion mode.", + "warning", + ) + self.error_callback("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, + ), + ) + def play(self, sound_path: str, keep: bool = False, volume: float = 1.0) -> bool: """Play a sound via Celune's pipeline. diff --git a/celune/cevoice.py b/celune/cevoice.py index 5fb4709..aec0973 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 @@ -211,6 +213,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) @@ -243,6 +246,7 @@ def materialize(self, voice: str, kind: str, suffix: Optional[str] = None) -> Pa def close(self) -> None: """Remove extracted temporary files.""" + unregister_protected_temp_path(self._directory) shutil.rmtree(self._directory, ignore_errors=True) @@ -607,6 +611,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: diff --git a/celune/dataclasses/celune.py b/celune/dataclasses/celune.py index 62ed39d..33c59e0 100644 --- a/celune/dataclasses/celune.py +++ b/celune/dataclasses/celune.py @@ -144,6 +144,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 @@ -282,6 +283,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/extensions.py b/celune/dataclasses/extensions.py index 07afcd3..a48d7de 100644 --- a/celune/dataclasses/extensions.py +++ b/celune/dataclasses/extensions.py @@ -1,6 +1,9 @@ """Extension-facing dataclasses.""" from dataclasses import dataclass, field +from contextlib import AbstractContextManager +from pathlib import Path +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/pipeline.py b/celune/dataclasses/pipeline.py index 7971a4d..6aab443 100644 --- a/celune/dataclasses/pipeline.py +++ b/celune/dataclasses/pipeline.py @@ -82,6 +82,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 diff --git a/celune/extensions/base.py b/celune/extensions/base.py index 35750a8..7075763 100644 --- a/celune/extensions/base.py +++ b/celune/extensions/base.py @@ -2,7 +2,8 @@ """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 @@ -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/paths.py b/celune/paths.py index 881f130..72e5dd0 100644 --- a/celune/paths.py +++ b/celune/paths.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: MIT """Runtime filesystem paths for Celune user data.""" +import os import sys import shutil from pathlib import Path @@ -11,6 +12,9 @@ from .constants import APP_SLUG _REPO_MARKERS = ("celune", "default_config.yaml", "pyproject.toml") +_HF_HOME_ENV = "HF_HOME" +_HF_HUB_CACHE_ENV = "HF_HUB_CACHE" +_TRANSFORMERS_CACHE_ENV = "TRANSFORMERS_CACHE" def running_compiled() -> bool: @@ -54,6 +58,79 @@ def app_data_dir(create: bool = False) -> Path: 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 transformers_cache_dir(create: bool = False) -> Path: + """Return Celune's default Transformers cache directory. + + Args: + create: Whether this directory should be created before being returned. + + Returns: + Path: Celune's Transformers cache directory. + """ + path = huggingface_home_dir(create=create) / "transformers" + 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()) + default_transformers_cache = str(transformers_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) + if os.environ.get(_TRANSFORMERS_CACHE_ENV) == default_transformers_cache: + os.environ.pop(_TRANSFORMERS_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 memory_data_dir(create: bool = False) -> Path: """Return the persistent memory directory. 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..c5d2718 100644 --- a/celune/persona/prompts.py +++ b/celune/persona/prompts.py @@ -2,12 +2,10 @@ """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: @@ -202,6 +200,15 @@ class PersonaContext: 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. @@ -273,15 +280,10 @@ def build(context: PersonaContext) -> str: f"{context.character_profile.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) + + # This debug dump helps inspect Persona prompt regressions without + # breaking prompt generation when the runtime data directory is unavailable. + PersonaPromptBuilder._write_debug_prompt(prompt) + + return prompt diff --git a/celune/pipeline.py b/celune/pipeline.py index 7f7cc80..947f3a0 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -31,6 +31,7 @@ from . import __version__ from .dataclasses.pipeline import ( + AudioOutput, AudioInputRequest, PlaybackChunk, PlaybackSourceDone, @@ -59,6 +60,7 @@ persona_short_term_history_limit, persona_style_traits, ) +from .cevoice import default_loader from .dsp import ( resample_audio, soften, @@ -697,6 +699,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: @@ -705,6 +708,7 @@ 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, ) @@ -1447,23 +1451,9 @@ def handle_audio_input(engine: Celune, request: AudioInputRequest) -> bool: """ audio = np.asarray(request.audio, dtype=np.float32) if getattr(engine, "input_mode", "text_to_speech") == "voice_conversion": - backend = getattr(engine, "vc_backend", None) - if backend is None: - engine.log("Voice conversion backend is not configured.", "warning") - engine.error_callback("Voice conversion backend is not configured") - engine.progress_callback(0, 1) + output = convert_audio_input(engine, request) + if output is None: return False - - output = backend.convert( - VoiceConversionRequest( - source_audio=audio, - sample_rate=request.sample_rate, - target_voice=getattr(engine, "current_voice", None), - target_character=getattr(engine, "current_character", None), - target_references=(), - label=request.label, - ) - ) return queue_sfx_audio( engine, output.audio, @@ -1478,6 +1468,47 @@ def handle_audio_input(engine: Celune, request: AudioInputRequest) -> bool: 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("Voice conversion backend is not configured.", "warning") + engine.error_callback("Voice conversion backend is not configured") + 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: + target_references = () + + return 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, + ) + ) + + def queue_speech( engine: Celune, text: str, @@ -1879,7 +1910,7 @@ def play_signal(engine: Celune, signal_type: str) -> bool: # Celune._try_play_signal() instead of calling this method directly 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") @@ -1888,6 +1919,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 @@ -2394,8 +2426,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: @@ -2426,7 +2460,11 @@ def _finalize_playback_idle( engine.current_voice, ) - if not getattr(engine, "_ready_announced", False): + if ( + engine.cur_state == "idle" + and getattr(engine, "loaded", False) + and not getattr(engine, "_ready_announced", False) + ): engine.log("Ready to speak.") engine._ready_announced = True @@ -2657,7 +2695,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() ): @@ -2666,6 +2705,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 = [ @@ -2688,7 +2735,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() ): @@ -2697,6 +2745,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/typing/celune.py b/celune/typing/celune.py index d74312f..76fee52 100644 --- a/celune/typing/celune.py +++ b/celune/typing/celune.py @@ -247,6 +247,7 @@ class CeluneStateAccessors: regenerate: bool locked: bool loaded: bool + _reload_pending: bool sleeping: bool _last_flavor: Optional[str] _ready_announced: bool diff --git a/celune/ui/app.py b/celune/ui/app.py index cf70873..adf970f 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -1107,8 +1107,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: @@ -1514,6 +1515,12 @@ def tts_idle(self) -> None: 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("Waking up") + return self.celune.locked = False if self.celune.sleeping: self.safe_status("Sleeping", "sleeping") diff --git a/celune/ui/commands.py b/celune/ui/commands.py index 3df719d..7dd1129 100644 --- a/celune/ui/commands.py +++ b/celune/ui/commands.py @@ -14,6 +14,7 @@ from ..paths import project_root from ..constants import APP_NAME from ..backends.qwen3 import Qwen3 +from ..cevoice import active_bundle_path, resolve_bundle_path from ..exceptions import InvalidExtensionError from ..utils import format_error, replace_ipa, format_number @@ -178,6 +179,8 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None: ui.safe_log("/speed - Change speaking speed.") ui.safe_log("/reverb - Change reverb strength.") + ui.safe_log("/backend - Hot-reload a TTS backend.") + ui.safe_log("/cevoice - Hot-reload a CEVOICE pack.") if ui.celune.backend.name == "qwen3": ui.safe_log( @@ -317,6 +320,62 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None: else: ui.safe_log(f"Reverb strength set to {args[0]}%.") return + if command == "backend": + if not args: + ui.safe_log("Usage: /backend ", "warning") + return + + backend_name = args[0] + + if backend_name == ui.celune.backend.name: + ui.safe_log("This backend is 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(f"Switched to backend: {backend_name}") + else: + ui.safe_log( + "Backend was not switched.", + "warning", + ) + except Exception as exc: + ui.safe_log( + f"Failed to switch backend: {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("Usage: /cevoice ", "warning") + return + + bundle = args[0] + if resolve_bundle_path(bundle) == active_bundle_path(): + ui.safe_log("This character is already loaded.", "warning") + return + + def cevoice_worker() -> None: + try: + if ui.celune.set_cevoice_and_wait(bundle): + ui.safe_log(f"Character changed: {bundle}") + else: + ui.safe_log( + f"Could not switch character to {bundle}.", + "warning", + ) + except Exception as exc: + ui.safe_log( + f"Cannot switch to this character: {format_error(exc, ui.celune.dev)}", + "error", + ) + + threading.Thread(target=cevoice_worker, daemon=True).start() + return if command == "xvectoronly": backend = ui.celune.backend if not isinstance(backend, Qwen3): 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/tests/test_api_audio.py b/tests/test_api_audio.py index 643b7ce..72c1433 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 from the engine.""" + 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"], "24000") + 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, 24000) + self.assertEqual(decoded_audio.shape, (12, 2)) diff --git a/tests/test_api_webui.py b/tests/test_api_webui.py index 1e52ec5..2ca9fe4 100644 --- a/tests/test_api_webui.py +++ b/tests/test_api_webui.py @@ -448,6 +448,71 @@ 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 browser 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)) + ) + + self.assertIsNone(source_value) + self.assertIsInstance(browser_audio, tuple) + sample_rate, array = cast(tuple[int, np.ndarray], browser_audio) + self.assertEqual(sample_rate, 24000) + self.assertEqual(array.shape, (10, 2)) + + 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("voice conversion mode", logs_html) + 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( diff --git a/tests/test_backends_and_extensions.py b/tests/test_backends_and_extensions.py index e8fb941..2a934fd 100644 --- a/tests/test_backends_and_extensions.py +++ b/tests/test_backends_and_extensions.py @@ -6,8 +6,9 @@ import textwrap import importlib import threading +import contextlib from pathlib import Path -from typing import Optional +from typing import Optional, cast from types import SimpleNamespace from unittest import mock, TestCase from collections.abc import Iterator @@ -18,6 +19,7 @@ import torch from celune.utils import discard +from celune.celune import Celune from celune.backends import resolve_backend from celune.vc_backends import resolve_vc_backend from celune.vc_backends.passthrough import CelunePassthroughVCBackend @@ -25,7 +27,6 @@ from celune.extensions.base import CeluneContext, CeluneExtension from celune.dataclasses.pipeline import VoiceConversionRequest from celune.exceptions import ( - BackendError, ExtensionAlreadyRegisteredError, InvalidExtensionError, ) @@ -128,6 +129,38 @@ def test_resolve_vc_backend_accepts_instance_type_and_rejects_unknown(self) -> N 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(object, 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) @@ -236,7 +269,7 @@ def test_voxcpm2_requires_reference_text_for_valid_voice_identifiers(self) -> No "celune.backends.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) @@ -296,7 +329,7 @@ def test_voxcpm2_requires_a_compatible_voice_pack(self) -> None: mock.patch("celune.backends.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) @@ -307,7 +340,7 @@ def test_mini_requires_reference_text_for_valid_voice_identifiers(self) -> None: loader = make_voice_loader("calm", {}) with mock.patch("celune.backends.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) @@ -389,7 +422,7 @@ def test_mini_requires_a_compatible_voice_pack(self) -> None: mock.patch("celune.backends.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) @@ -611,7 +644,7 @@ def test_dotstts_requires_reference_text_for_valid_voice_identifiers(self) -> No "celune.backends.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) @@ -623,7 +656,7 @@ def test_dotstts_requires_a_compatible_voice_pack(self) -> None: mock.patch("celune.backends.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) @@ -718,7 +751,7 @@ def test_qwen3_requires_reference_text_for_valid_voice_identifiers(self) -> None "celune.backends.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) @@ -730,7 +763,7 @@ def test_qwen3_requires_a_compatible_voice_pack(self) -> None: mock.patch("celune.backends.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) @@ -750,7 +783,7 @@ def test_qwen3_requires_at_least_one_valid_voice_identifier(self) -> None: mock.patch("celune.backends.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) @@ -935,6 +968,12 @@ def setUp(self) -> None: set_voice=lambda name: True, get_state=lambda: "idle", wait_until_ready=lambda timeout=30.0: True, + backend_override=lambda backend_name: contextlib.nullcontext( + cast(Celune, SimpleNamespace()) + ), + cevoice_override=lambda bundle: contextlib.nullcontext( + cast(Celune, SimpleNamespace()) + ), ) def test_context_and_extension_helpers_delegate_calls(self) -> None: @@ -956,6 +995,10 @@ 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 def test_manager_registers_invokes_and_autoloads_extensions(self) -> None: """Verify registration, duplicate handling, and directory autoloading. diff --git a/tests/test_celune_core.py b/tests/test_celune_core.py index 4ad6c35..928d9d4 100644 --- a/tests/test_celune_core.py +++ b/tests/test_celune_core.py @@ -2,6 +2,9 @@ """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 @@ -12,10 +15,16 @@ 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.constants import JSONSerializable -from celune.pipeline import handle_audio_input, 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 @@ -42,6 +51,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. @@ -123,6 +151,69 @@ 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_persona_connection_uses_in_process_runtime(self) -> None: """Verify Celune connects to Persona through the local in-process runtime. @@ -201,6 +292,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({}) @@ -587,6 +705,580 @@ def test_submit_audio_routes_to_vc_backend_in_voice_conversion_mode(self) -> Non 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.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.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() + queue_sfx.assert_not_called() + + 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): + del model, 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]] = [] + + def record_signal(signal_type: str) -> bool: + signal_states.append((signal_type, celune.cur_state, celune.loaded)) + 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)]) + 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.tts_backend = "mini" + celune.set_backend = mock.Mock(return_value=True) + celune._model_ready.wait = mock.Mock(return_value=True) + + 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) + + 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)", + "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) + del model, 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.default_bundle_path", return_value=Path("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({}) @@ -932,3 +1624,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_extension_events.py b/tests/test_extension_events.py index 3a15407..7211a43 100644 --- a/tests/test_extension_events.py +++ b/tests/test_extension_events.py @@ -120,7 +120,11 @@ class EventExtension(CeluneExtension): @subscribe("ready") def on_ready(self, _event: ReadyEvent) -> None: - """React to a mock ready event.""" + """React to a mock ready event. + + Args: + _event: The ready event payload. + """ received.append("ready") def invoke(self, *args, **kwargs) -> None: @@ -146,12 +150,20 @@ class EventExtension(CeluneExtension): @subscribe("ready", enabled=False) def on_ready(self, _event: ReadyEvent) -> None: - """React to a mock ready event.""" + """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.""" + """React to a mock voice change event. + + Args: + _event: The voice-change event payload. + """ received.append("voice") def invoke(self, *args, **kwargs) -> None: 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 c3a162a..aa2fa8e 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -24,7 +24,7 @@ from celune.persona.prompts import PersonaPromptBuilder from celune.constants import JSON, JSONSerializable, PipelineStates from celune.cevoice import CEVoicePersona, PersonaIdentity, PersonaStyleValues -from .support import FakeStream, FakeVCBackend, make_pipeline_engine +from .support import FakeStream, FakeVCBackend, make_pipeline_engine, make_voice_loader from .test_persona_memory import StubEmbeddingMemoryStore @@ -129,6 +129,21 @@ 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_queue_speech_handles_success_and_failure_paths(self) -> None: """Verify speech queueing success and rejection paths. @@ -247,10 +262,26 @@ def test_handle_audio_input_routes_to_vc_backend_in_voice_conversion_mode( audio = np.ones((16, 2), dtype=np.float32) request = AudioInputRequest(audio=audio, sample_rate=48000, label="mic test") - with mock.patch("celune.pipeline.queue_sfx_audio", return_value=True) as queue: + 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"),)) queue.assert_called_once() queued_audio = queue.call_args.args[1] self.assertEqual(queue.call_args.args[2], 48000) @@ -606,6 +637,45 @@ def test_playback_worker_mixes_sources_and_glow_receives_mixed_audio(self) -> No 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_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() @@ -893,6 +963,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. diff --git a/tests/test_runtime_and_ui_commands.py b/tests/test_runtime_and_ui_commands.py index c6853b4..951a06b 100644 --- a/tests/test_runtime_and_ui_commands.py +++ b/tests/test_runtime_and_ui_commands.py @@ -178,7 +178,7 @@ def setUp(self) -> None: self.ui.safe_log_dev = self.ui.safe_log self.ui.celune = SimpleNamespace( config={"ipa": False}, - backend=SimpleNamespace(), + backend=FakeBackend, voice_prompt=None, persona_attachments=[], can_use_rubberband=True, @@ -186,7 +186,9 @@ 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, ) def _process_command(self, command: str, args: list[str]) -> None: @@ -238,6 +240,65 @@ 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_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: @@ -510,6 +571,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() @@ -873,6 +964,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..3ca20ca 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, + ensure_config_path, + huggingface_home_dir, + huggingface_hub_cache_dir, + project_root, + running_compiled, + transformers_cache_dir, +) 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", + ) + self.assertEqual( + transformers_cache_dir(), + expected_root / "huggingface" / "transformers", + ) + + 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"), + "TRANSFORMERS_CACHE": str( + expected_root / "huggingface" / "transformers" + ), + }, + clear=True, + ), + ): + configure_huggingface_cache_environment() + self.assertNotIn("HF_HOME", os.environ) + self.assertNotIn("HF_HUB_CACHE", os.environ) + self.assertNotIn("TRANSFORMERS_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", + "TRANSFORMERS_CACHE": "X:/legacy-transformers-cache", + } + + 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"]) + self.assertEqual( + os.environ["TRANSFORMERS_CACHE"], + existing["TRANSFORMERS_CACHE"], + ) + def test_format_error_writes_traceback_to_runtime_directory(self) -> None: """Verify developer tracebacks are saved via the runtime path helper. From 91212a73e4361646bc97bfabeeffaa668e7ebd23 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Sun, 21 Jun 2026 18:02:10 +0200 Subject: [PATCH 04/26] feat: better persona prompt handling with emotions and CEVOICE-driven metadata --- celune/__init__.py | 8 +- celune/api.py | 27 +- celune/backends/base.py | 78 +++- celune/backends/dotstts.py | 4 +- celune/celune.py | 67 ++-- celune/cevoice.py | 41 +- celune/chroma.py | 4 +- celune/constants.py | 2 + celune/extensions/events.py | 48 ++- celune/extensions/manager.py | 13 +- celune/paths.py | 16 +- celune/persona/emotion.py | 514 ++++++++++++++++++++++++++ celune/persona/impl.py | 4 +- celune/persona/prompts.py | 145 ++++---- celune/pipeline.py | 161 ++++---- celune/terminal.py | 4 +- celune/typing/celune.py | 7 +- celune/ui/app.py | 15 +- extensions/test.py | 4 +- tests/support.py | 3 +- tests/test_backends_and_extensions.py | 3 +- tests/test_celune_core.py | 64 +++- tests/test_cevoice.py | 16 + tests/test_extension_events.py | 43 ++- tests/test_persona_emotion.py | 124 +++++++ tests/test_pipeline.py | 271 ++++++++------ tests/test_runtime_and_ui_commands.py | 28 ++ tests/test_runtime_paths.py | 23 ++ 28 files changed, 1316 insertions(+), 421 deletions(-) create mode 100644 celune/persona/emotion.py create mode 100644 tests/test_persona_emotion.py diff --git a/celune/__init__.py b/celune/__init__.py index efae663..5b712b8 100644 --- a/celune/__init__.py +++ b/celune/__init__.py @@ -20,9 +20,13 @@ from typing import TYPE_CHECKING, Callable, Union from .constants import APP_NAME -from .paths import configure_huggingface_cache_environment +from .paths import ( + configure_huggingface_cache_environment, + configure_huggingface_runtime, +) configure_huggingface_cache_environment() +configure_huggingface_runtime() if TYPE_CHECKING: from .celune import Celune @@ -92,7 +96,7 @@ def __getattr__( type["Celune"], type["CeluneContext"], type["CeluneExtension"], - Callable[..., object], + Callable[..., Callable[..., None]], ]: if name == "Celune": from .celune import Celune diff --git a/celune/api.py b/celune/api.py index bc4c5f2..2e3d73a 100644 --- a/celune/api.py +++ b/celune/api.py @@ -691,7 +691,7 @@ 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 f"{APP_NAME} could not start", "error" state = (celune.cur_state or "").strip().lower() return { @@ -1487,17 +1487,6 @@ def _build_webui() -> gr.Blocks: min_width=0, interactive=False, ) - source_audio = gr.Audio( - value=None, - type="numpy", - sources=["upload", "microphone"], - label="Source audio", - elem_id="celune-source-audio", - ) - convert_button = gr.Button( - value="Convert Audio", - elem_id="celune-convert-audio", - ) with gr.Row(elem_id="celune-footer"): status = gr.HTML(_webui_status_html(), elem_id="celune-status") resources = gr.HTML( @@ -1564,20 +1553,6 @@ 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], - outputs=[ - source_audio, - audio, - logs, - status, - resources, - voice_button, - send_button, - ], - show_progress="hidden", - ) return demo diff --git a/celune/backends/base.py b/celune/backends/base.py index 30a6402..7e3401b 100644 --- a/celune/backends/base.py +++ b/celune/backends/base.py @@ -12,8 +12,17 @@ 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 @@ -41,14 +50,54 @@ _RUNTIME_PRIMITIVE_TYPES = (str, bytes, bytearray, int, float, bool, type(None)) -def _call_runtime_hook_if_present(value: object, name: str) -> bool: +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.""" - with contextlib.suppress(TypeError): - existing = vars(value).get(name) - if callable(existing): - with contextlib.suppress(Exception): - existing() - return True + 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 @@ -124,7 +173,7 @@ 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: object, seen: set[int]) -> None: +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()): @@ -152,10 +201,13 @@ def _release_runtime_container_members(value: object, seen: set[int]) -> None: _release_runtime_references(nested, seen) -def _release_runtime_object_members(value: object, seen: set[int]) -> None: +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(vars(value).items()) + members = list(cast(dict[str, RuntimeValue], value.__dict__).items()) for attr_name, attr_value in members: if attr_value is value: continue @@ -166,7 +218,7 @@ def _release_runtime_object_members(value: object, seen: set[int]) -> None: setattr(value, attr_name, None) -def _release_runtime_references(value: object, seen: set[int]) -> 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 diff --git a/celune/backends/dotstts.py b/celune/backends/dotstts.py index b80db54..25ded35 100644 --- a/celune/backends/dotstts.py +++ b/celune/backends/dotstts.py @@ -12,7 +12,7 @@ import numpy.typing as npt from dots_tts.runtime import DotsTtsRuntime -from ..utils import custom_assert +from ..utils import custom_assert, discard from ..cevoice import default_loader, CEVoiceLoader from .base import CeluneBackend, cached_hf_snapshot_path, local_hf_offline_mode @@ -212,7 +212,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, [ diff --git a/celune/celune.py b/celune/celune.py index 04fd419..6f811a2 100644 --- a/celune/celune.py +++ b/celune/celune.py @@ -9,17 +9,14 @@ import threading import contextlib from pathlib import Path -from typing import Optional, Callable, Union, cast +from typing import Optional, Callable, Protocol, Union, cast from dataclasses import dataclass 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 ( @@ -70,6 +67,7 @@ ReleasableObject, 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 ( @@ -92,8 +90,8 @@ 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, @@ -122,6 +120,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 @@ -194,7 +200,7 @@ class _ReloadSnapshot: backend_spec: Optional[Union[str, type[CeluneBackend]]] backend_kwargs: dict[str, JSONSerializable] tts_backend: str - model: Optional[object] + model: Optional[BackendModel] model_name: str voices: tuple[str, ...] current_voice: Optional[str] @@ -480,14 +486,7 @@ def _emit_event(self, event_name: EventName, event: EventPayload) -> None: @staticmethod def _is_ephemeral_temp_path(path: Path) -> bool: - """Return whether one temp path is safe for residual cleanup. - - Args: - path: The temp file or directory to classify. - - Returns: - bool: ``True`` when the path matches Celune's disposable temp artifacts. - """ + """Return whether one temp path is safe for residual cleanup.""" if path.is_dir(): return path.name.startswith(_EPHEMERAL_TEMP_DIR_PREFIXES) @@ -497,11 +496,7 @@ def _is_ephemeral_temp_path(path: Path) -> bool: 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. - - Args: - temp_dir: The Celune temp directory to scan. - """ + """Delete only Celune's disposable residual temp artifacts.""" disposable_paths = [ path for path in temp_dir.iterdir() @@ -529,12 +524,11 @@ def _cleanup_residual_temp_data(self, temp_dir: Path) -> None: path.unlink(missing_ok=True) @staticmethod - def _bundle_path_string(bundle: object) -> Optional[str]: + def _bundle_path_string(bundle: Optional[_BundleWithPath]) -> Optional[str]: """Return one bundle path as a string when it is available.""" - path = getattr(bundle, "path", None) - if path is None: + if bundle is None: return None - return str(path) + return str(bundle.path) def _emit_character_event_transition( self, @@ -726,7 +720,7 @@ def _capture_reload_snapshot(self) -> _ReloadSnapshot: backend_spec=self._backend_spec, backend_kwargs=dict(self._backend_kwargs), tts_backend=self.tts_backend, - model=cast(Optional[object], self.model), + model=self.model, model_name=self.model_name, voices=self.voices, current_voice=self.current_voice, @@ -785,7 +779,7 @@ def _rebuild_reload_snapshot_runtime(self, snapshot: _ReloadSnapshot) -> None: ) snapshot.backend = restored_backend - snapshot.model = cast(Optional[object], restored_model) + snapshot.model = cast(Optional[BackendModel], restored_model) snapshot.model_name = restored_model_name def _resolve_voice_state( @@ -824,7 +818,7 @@ def _resolve_voice_state( current_voice, bundle_character_name(loader.bundle), persona_metadata_from_manifest(loader.bundle.metadata), - loader.bundle.path == default_bundle_path(), + bundle_matches_default_pack_checksum(loader.bundle.path), ) voices = tuple(backend.voices) @@ -1360,7 +1354,9 @@ def load_voice_bundle(self, bundle: Optional[Union[str, Path]] = None) -> bool: return bool(voices) new_bundle_path = self._bundle_path_string(loader.bundle) - self.voice_bundle_is_default = loader.bundle.path == default_bundle_path() + self.voice_bundle_is_default = bundle_matches_default_pack_checksum( + loader.bundle.path + ) self.current_character_persona = persona_metadata_from_manifest( loader.bundle.metadata ) @@ -1507,8 +1503,8 @@ def set_backend_and_wait( 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. + 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. @@ -1567,8 +1563,8 @@ def set_cevoice_and_wait( 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. + 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. @@ -1861,10 +1857,6 @@ 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._active_runtime_backend_name()) self._cleanup_residual_temp_data(app_data_dir() / "temp") if not self.load_available_voices(): @@ -1882,10 +1874,7 @@ 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": + if self.voice_bundle_is_default: self.log(f"Current character: {character} (default)") else: self.log(f"Current character: {character}") diff --git a/celune/cevoice.py b/celune/cevoice.py index aec0973..f7826e2 100644 --- a/celune/cevoice.py +++ b/celune/cevoice.py @@ -32,6 +32,9 @@ HEADER = struct.Struct("<8sHI") ALLOWED_ASSET_KINDS = {"wav", "pt"} +DEFAULT_CEVOICE_PACK_SHA256: Final[str] = ( + "22ff70762e7f6f3e734cc62c81c286f7482de6155b2394e7a6ddec1a892f63e0" +) @dataclass(frozen=True) @@ -232,17 +235,20 @@ 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.""" @@ -672,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/constants.py b/celune/constants.py index 6e5ab4a..ce3c385 100644 --- a/celune/constants.py +++ b/celune/constants.py @@ -30,6 +30,8 @@ VOICE_EMBEDDING_MODEL = "marksverdhei/Qwen3-Voice-Embedding-12Hz-1.7B" # 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" diff --git a/celune/extensions/events.py b/celune/extensions/events.py index 87c5a40..6448be4 100644 --- a/celune/extensions/events.py +++ b/celune/extensions/events.py @@ -15,7 +15,9 @@ CharacterLoadedEventCallback, CharacterUnloadedEventCallback, ErrorEventCallback, + EventCallback, EventName, + EventPayload, FatalEventCallback, GenerationEndEventCallback, GenerationErrorEventCallback, @@ -64,7 +66,8 @@ ) _EVENT_NAME_SET = frozenset(EVENT_NAMES) EVENT_HANDLER_METADATA_ATTR = "__celune_event_subscriptions__" -_DecoratedCallback = TypeVar("_DecoratedCallback", bound=Callable[..., object]) +_DecoratedCallback = TypeVar("_DecoratedCallback", bound=Callable[..., None]) +_DispatcherCallback = Callable[[EventPayload], None] @dataclass(frozen=True) @@ -80,7 +83,7 @@ class RegisteredEventHandler: """Stored event-subscription metadata used for later cleanup.""" event_name: EventName - callback: Callable[[object], None] + callback: EventCallback owner_name: str @@ -96,10 +99,8 @@ def __init__( self._log_warning = log_warning self._dev = dev self._lock = threading.RLock() - self._callbacks: dict[EventName, list[Callable[[object], None]]] = defaultdict( - list - ) - self._owners: dict[tuple[EventName, Callable[[object], None]], str] = {} + self._callbacks: dict[EventName, list[_DispatcherCallback]] = defaultdict(list) + self._owners: dict[tuple[EventName, _DispatcherCallback], str] = {} @overload def subscribe( @@ -213,6 +214,14 @@ def subscribe( 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, @@ -229,7 +238,7 @@ def subscribe( self._validate_event_name(event_name) with self._lock: callbacks = self._callbacks[event_name] - typed_callback = cast(Callable[[object], None], callback) + typed_callback = cast(_DispatcherCallback, callback) if typed_callback not in callbacks: callbacks.append(typed_callback) self._owners[(event_name, typed_callback)] = ( @@ -334,6 +343,13 @@ def unsubscribe( callback: CharacterUnloadedEventCallback, ) -> None: ... + @overload + def unsubscribe( + self, + event_name: EventName, + callback: EventCallback, + ) -> None: ... + def unsubscribe( self, event_name: EventName, @@ -350,7 +366,7 @@ def unsubscribe( callbacks = self._callbacks.get(event_name) if callbacks is None: return - typed_callback = cast(Callable[[object], None], callback) + typed_callback = cast(_DispatcherCallback, callback) try: callbacks.remove(typed_callback) except ValueError: @@ -434,9 +450,9 @@ def emit( ) -> None: ... @overload - def emit(self, event_name: EventName, event: object) -> None: ... + def emit(self, event_name: EventName, event: EventPayload) -> None: ... - def emit(self, event_name: EventName, event: object) -> None: + def emit(self, event_name: EventName, event: EventPayload) -> None: """Dispatch an event to all current subscribers. Args: @@ -473,7 +489,7 @@ def _validate_event_name(event_name: EventName) -> None: raise ValueError(f"unknown event name: {event_name}") @staticmethod - def _describe_callback(callback: Callable[[object], None]) -> str: + def _describe_callback(callback: _DispatcherCallback) -> str: """Return a useful callback label for logs.""" qualname = getattr(callback, "__qualname__", None) if isinstance(qualname, str) and qualname: @@ -485,10 +501,10 @@ def _describe_callback(callback: Callable[[object], None]) -> str: def _store_subscription_metadata( - callback: Callable[[object], None], + callback: Callable[..., None], event_name: EventName, enabled: bool, -) -> Callable[[object], None]: +) -> Callable[..., None]: """Attach one declared event subscription to a callback.""" EventDispatcher._validate_event_name(event_name) subscription = EventSubscription(event_name=event_name, enabled=enabled) @@ -506,7 +522,7 @@ def _store_subscription_metadata( def iter_subscriptions( - callback: object, + callback: Callable[..., None], ) -> tuple[EventSubscription, ...]: """Return the declared event subscriptions stored on a callback. @@ -654,14 +670,14 @@ def subscribe( enabled: Whether the handler should be auto-registered when discovered. Returns: - Callable[..., object]: Decorator that records the event name on the callback. + Callable[..., Callable[..., None]]: Decorator that records the event name on the callback. """ def decorator(callback: _DecoratedCallback) -> _DecoratedCallback: return cast( _DecoratedCallback, _store_subscription_metadata( - cast(Callable[[object], None], callback), + cast(_DispatcherCallback, callback), event_name, enabled, ), diff --git a/celune/extensions/manager.py b/celune/extensions/manager.py index 988e4d2..e7e08e7 100644 --- a/celune/extensions/manager.py +++ b/celune/extensions/manager.py @@ -13,13 +13,14 @@ from ..dataclasses.events import ReadyEvent from ..typing.events import EventName -from ..utils import format_error +from ..utils import format_error, discard from .base import CeluneContext, CeluneExtension from .events import ( EventDispatcher, RegisteredEventHandler, iter_subscriptions, ) +from ..typing.events import EventPayload from ..exceptions import InvalidExtensionError, ExtensionAlreadyRegisteredError @@ -113,7 +114,7 @@ def unregister_all(self) -> None: for name in list(self.extensions.keys()): self.unregister(name) - def emit(self, event_name: EventName, event: object) -> None: + def emit(self, event_name: EventName, event: EventPayload) -> None: """Forward one event to the shared dispatcher. Args: @@ -299,7 +300,7 @@ def _register_extension_handlers(self, extension: CeluneExtension) -> None: registered = self._register_handler( owner_name=extension.name, event_name=subscription.event_name, - callback=callback, + callback=cast(Callable[..., None], callback), ) handlers.append(registered) @@ -322,7 +323,7 @@ def _register_legacy_autostart_handler(self, extension: CeluneExtension) -> None def legacy_ready_callback( event: ReadyEvent, ext: CeluneExtension = extension ) -> None: - del event + discard(event) def runner() -> None: try: @@ -400,13 +401,13 @@ def _register_handler( *, owner_name: str, event_name: EventName, - callback: object, + 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[[object], None], callback) + typed_callback = cast(Callable[[EventPayload], None], callback) self.dispatcher.subscribe( event_name, typed_callback, diff --git a/celune/paths.py b/celune/paths.py index 72e5dd0..312ff48 100644 --- a/celune/paths.py +++ b/celune/paths.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: MIT -"""Runtime filesystem paths for Celune user data.""" +"""Runtime filesystem paths and global Hugging Face runtime setup for Celune.""" +import logging import os import sys import shutil @@ -8,12 +9,16 @@ 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 transformers.utils import logging as hf_logging from .constants import 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" _TRANSFORMERS_CACHE_ENV = "TRANSFORMERS_CACHE" @@ -131,6 +136,15 @@ def configure_huggingface_cache_environment(force: bool = False) -> None: os.environ[_HF_HUB_CACHE_ENV] = default_hf_hub_cache +def configure_huggingface_runtime() -> None: + """Apply Celune's process-wide Hugging Face logging and progress suppression.""" + os.environ.setdefault(_HF_HUB_DISABLE_PROGRESS_BARS_ENV, "1") + disable_progress_bar() + disable_progress_bars() + hf_logging.set_verbosity_error() + logging.getLogger("huggingface_hub").setLevel(logging.ERROR) + + 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..7097407 --- /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 import AutoModel, AutoModelForSequenceClassification, AutoTokenizer +from transformers.modeling_utils import PreTrainedModel +from transformers.tokenization_utils_base import PreTrainedTokenizerBase + +from ..constants import JSONSerializable, PERSONA_EMOTION_MODEL +from ..utils import discard + +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..2ad345f 100644 --- a/celune/persona/impl.py +++ b/celune/persona/impl.py @@ -247,9 +247,7 @@ def uses_default_celune_identity(engine: PersonaEngineView) -> bool: Returns: bool: ``True`` when the default Celune voice bundle is active for Celune. """ - if not bool(getattr(engine, "voice_bundle_is_default", False)): - return False - return persona_active_character_name(engine).strip().lower() == "celune" + return bool(getattr(engine, "voice_bundle_is_default", False)) def default_persona_persona() -> str: diff --git a/celune/persona/prompts.py b/celune/persona/prompts.py index c5d2718..88f6d66 100644 --- a/celune/persona/prompts.py +++ b/celune/persona/prompts.py @@ -1,7 +1,6 @@ # SPDX-License-Identifier: MIT """Structured prompt building for the Persona system.""" -import textwrap import contextlib from dataclasses import dataclass, field @@ -48,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: @@ -103,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: @@ -151,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. " @@ -166,34 +201,17 @@ 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 = "" @@ -219,71 +237,48 @@ 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", - ), - _render_optional_section( - "short_term_memory", + "history", context.current_run_chat_history.render(context.user_message.strip()), ), _render_optional_section( - "vision_context", - context.visual_context.render(), + "mood", + context.mood_or_state.strip() or "neutral", ), - 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"\n{_render_lines(behavior_lines)}\n".strip(), + f"{name}:", ] prompt = "\n\n".join(section for section in sections if section) - - # This debug dump helps inspect Persona prompt regressions without - # breaking prompt generation when the runtime data directory is unavailable. PersonaPromptBuilder._write_debug_prompt(prompt) - return prompt diff --git a/celune/pipeline.py b/celune/pipeline.py index 947f3a0..f787ed9 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -41,6 +41,7 @@ ) from .exceptions import NotAvailableError 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 ( @@ -87,13 +88,13 @@ PersonaPromptBuilder, RetrievedMemoryBundle, ShortTermHistory, - VisualContext, ) from .constants import ( APP_NAME, APP_SLUG, BASE_SR, JSON, + PERSONA_EMOTION_MODEL, JSONSerializable, PERSONA_MEMORY_EMBEDDING_MODEL, ) @@ -997,89 +998,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) @@ -1097,6 +1015,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) @@ -1244,17 +1228,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(), ) @@ -1371,7 +1352,6 @@ 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 @@ -1395,8 +1375,6 @@ def think(engine: Celune, request: str) -> bool: engine.log("Persona system returned an empty response.", "warning") return False - _remember_visual_context(attachment_snapshot, engine, request) - history = getattr(engine, "persona_history", None) if isinstance(history, list): history.extend( @@ -2492,7 +2470,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 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/celune.py b/celune/typing/celune.py index 76fee52..00095d5 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 @@ -29,6 +29,9 @@ from ..vc_backends import CeluneVCBackend +GenerationKwarg = Union[torch.Tensor, int, bool, None] + + class SupportsClose(Protocol): """Protocol for objects that can be closed.""" @@ -56,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: diff --git a/celune/ui/app.py b/celune/ui/app.py index adf970f..6b06e41 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -16,7 +16,7 @@ from dataclasses import dataclass, field from pathlib import Path from collections.abc import Iterator -from typing import Optional, Callable, Union, TextIO, cast +from typing import Optional, Callable, Protocol, Union, TextIO, cast import yaml from rich.text import Text @@ -361,7 +361,11 @@ def _clear_border_pulses(self) -> None: def _refresh_theme_text(self) -> None: """Refresh widgets after a runtime theme change.""" - def repaint(widget: object) -> None: + class _RefreshableWidget(Protocol): + def refresh(self, *args, **kwargs) -> object: + """Refresh one widget in place.""" + + def repaint(widget: _RefreshableWidget) -> None: refresh = getattr(widget, "refresh", None) if refresh is None: return @@ -698,7 +702,12 @@ def _install_runtime_log_redirects(self) -> None: self._runtime_redirect_original_handlers = {} self._runtime_redirect_original_propagate = {} - for logger_name in ("torch.utils.flop_counter", "py.warnings"): + for logger_name in ( + "torch.utils.flop_counter", + "py.warnings", + "huggingface_hub", + "transformers", + ): logger = logging.getLogger(logger_name) handler = UILogHandler(self.safe_log) self._runtime_redirect_loggers[logger_name] = logger diff --git a/extensions/test.py b/extensions/test.py index 7db4265..22068ba 100644 --- a/extensions/test.py +++ b/extensions/test.py @@ -6,6 +6,7 @@ import celune from celune import CeluneExtension +from celune.utils import discard class TestExtension(CeluneExtension): @@ -20,7 +21,8 @@ def on_ready(self, event) -> None: Args: event: Ready event emitted after Celune finishes startup. """ - del event + discard(event) + self.log("Log test") time.sleep(1) # due to threading, this does not block self.status("Status test") diff --git a/tests/support.py b/tests/support.py index 68018a9..9fb0a1d 100644 --- a/tests/support.py +++ b/tests/support.py @@ -86,7 +86,8 @@ 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} diff --git a/tests/test_backends_and_extensions.py b/tests/test_backends_and_extensions.py index 2a934fd..c070d19 100644 --- a/tests/test_backends_and_extensions.py +++ b/tests/test_backends_and_extensions.py @@ -21,6 +21,7 @@ from celune.utils import discard from celune.celune import Celune from celune.backends import resolve_backend +from celune.typing.backends import BackendModel from celune.vc_backends import resolve_vc_backend from celune.vc_backends.passthrough import CelunePassthroughVCBackend from celune.extensions.manager import CeluneExtensionManager @@ -153,7 +154,7 @@ def __init__(self) -> None: runtime = WrapperRuntime() inner = runtime.inner cached = runtime.cache["child"] - backend.model = cast(object, runtime) + backend.model = cast(BackendModel, runtime) backend.unload_model() diff --git a/tests/test_celune_core.py b/tests/test_celune_core.py index 928d9d4..1755140 100644 --- a/tests/test_celune_core.py +++ b/tests/test_celune_core.py @@ -28,6 +28,7 @@ from celune.vram import QWEN3_0_6B_MODEL from celune.persona.impl import persona_quantization from celune.exceptions import BackendError, WarmupError +from celune.utils import discard from .support import FakeBackend, FakeGlow, FakeVCBackend @@ -122,6 +123,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", @@ -140,7 +142,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") @@ -214,6 +222,51 @@ def test_cleanup_residual_temp_data_preserves_protected_temp_paths(self) -> None "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. @@ -782,7 +835,8 @@ class FailingWarmupBackend(FakeBackend): default_voice = "storm" def generate_stream(self, model, **kwargs: JSONSerializable): - del model, kwargs + discard(model) + discard(kwargs) raise RuntimeError("warmup blew up") celune = self._make_celune({}) @@ -1098,7 +1152,8 @@ class FailingWarmupBackend(FakeBackend): def generate_stream(self, model, **kwargs: JSONSerializable): observed_backend_names.append(celune.backend.name) - del model, kwargs + discard(model) + discard(kwargs) raise RuntimeError("warmup blew up") with mock.patch("celune.celune.play_signal", return_value=False): @@ -1239,7 +1294,8 @@ def fake_loader(): 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.default_bundle_path", return_value=Path("celune.cevoice") + "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"] 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_extension_events.py b/tests/test_extension_events.py index 7211a43..6148c07 100644 --- a/tests/test_extension_events.py +++ b/tests/test_extension_events.py @@ -5,6 +5,8 @@ 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 @@ -24,6 +26,7 @@ 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 @@ -39,37 +42,55 @@ def setUp(self) -> None: 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: object) -> None: + def first(_event: ReadyEvent) -> None: calls.append("first") - def second(_event: object) -> None: + def second(_event: ReadyEvent) -> None: calls.append("second") - self.dispatcher.subscribe("ready", first, owner_name="first") - self.dispatcher.subscribe("ready", second, owner_name="second") - self.dispatcher.emit("ready", object()) + 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", object()) + 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: object) -> None: + def broken(_event: ReadyEvent) -> None: calls.append("broken") raise RuntimeError("boom") - def healthy(_event: object) -> None: + def healthy(_event: ReadyEvent) -> None: calls.append("healthy") - self.dispatcher.subscribe("ready", broken, owner_name="broken") - self.dispatcher.subscribe("ready", healthy, owner_name="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", object()) + self.dispatcher.emit("ready", ready_event) self.assertEqual(calls, ["broken", "healthy"]) self.assertEqual(self.logs[-1][1], "warning") 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_pipeline.py b/tests/test_pipeline.py index aa2fa8e..1cc3605 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1104,23 +1104,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:], [ @@ -1195,7 +1190,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.", @@ -1222,59 +1216,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() @@ -1318,6 +1300,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) @@ -1325,6 +1316,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.""" @@ -1356,10 +1354,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.""" @@ -1372,8 +1368,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.""" @@ -1387,9 +1384,105 @@ 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) + + 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, @@ -1408,7 +1501,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: @@ -1430,7 +1523,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) @@ -1562,14 +1655,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, @@ -1591,7 +1681,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.", @@ -1599,7 +1689,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.""" @@ -1747,9 +1836,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( @@ -1757,59 +1844,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. diff --git a/tests/test_runtime_and_ui_commands.py b/tests/test_runtime_and_ui_commands.py index 951a06b..3661444 100644 --- a/tests/test_runtime_and_ui_commands.py +++ b/tests/test_runtime_and_ui_commands.py @@ -783,6 +783,34 @@ 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") + 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) + + logger.error("download failed because the connection dropped") + + self.assertEqual( + captured, + [ + ( + "Internal runtime error: download failed because the " + "connection dropped", + "error", + ) + ], + ) + def test_safe_status_marquees_long_text_for_narrow_status_label(self) -> None: """Verify long status text scrolls instead of clipping.""" diff --git a/tests/test_runtime_paths.py b/tests/test_runtime_paths.py index 3ca20ca..672e8ca 100644 --- a/tests/test_runtime_paths.py +++ b/tests/test_runtime_paths.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: MIT """Tests for Celune runtime path handling.""" +import logging import tempfile import sys import os @@ -14,6 +15,7 @@ from celune.constants import APP_SLUG from celune.paths import ( configure_huggingface_cache_environment, + configure_huggingface_runtime, ensure_config_path, huggingface_home_dir, huggingface_hub_cache_dir, @@ -176,6 +178,27 @@ def test_huggingface_cache_environment_keeps_non_celune_overrides( existing["TRANSFORMERS_CACHE"], ) + def test_huggingface_runtime_disables_global_progress_and_logs(self) -> None: + """Verify Celune globally suppresses Hugging Face progress and log noise.""" + with ( + mock.patch("celune.paths.disable_progress_bar") as disable_transformers, + mock.patch("celune.paths.disable_progress_bars") as disable_hub, + mock.patch("celune.paths.hf_logging.set_verbosity_error") as set_verbosity, + mock.patch("celune.paths.logging.getLogger") as get_logger, + mock.patch.dict(os.environ, {}, clear=True), + ): + logger = mock.Mock() + get_logger.return_value = logger + + 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() + set_verbosity.assert_called_once_with() + get_logger.assert_called_once_with("huggingface_hub") + logger.setLevel.assert_called_once_with(logging.ERROR) + def test_format_error_writes_traceback_to_runtime_directory(self) -> None: """Verify developer tracebacks are saved via the runtime path helper. From 50b3e67ce7b803a3c67e4392978acbb7227be345 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Sun, 21 Jun 2026 20:51:27 +0200 Subject: [PATCH 05/26] chore: import and type formatting --- celune/api.py | 16 +++++++------- celune/backends/__init__.py | 4 ++-- celune/backends/base.py | 4 ++-- celune/backends/mini.py | 3 +-- celune/celune.py | 4 ++-- celune/colors.py | 2 +- celune/dataclasses/__init__.py | 2 +- celune/dataclasses/celune.py | 16 +++++++------- celune/dataclasses/extensions.py | 2 +- celune/dataclasses/persona.py | 2 +- celune/dataclasses/pipeline.py | 4 ++-- celune/dataclasses/properties.py | 4 ++-- celune/entrypoint.py | 4 ++-- celune/extensions/base.py | 2 +- celune/extensions/events.py | 2 +- celune/extensions/manager.py | 8 +++---- celune/paths.py | 8 +++---- celune/persona/emotion.py | 4 ++-- celune/persona/runtime.py | 4 ++-- celune/pipeline.py | 10 ++++----- celune/typing/__init__.py | 2 +- celune/typing/analysis.py | 6 +++--- celune/typing/backends.py | 2 +- celune/typing/celune.py | 12 +++++------ celune/typing/cevoice.py | 6 +++--- celune/typing/common.py | 18 ++++++++-------- celune/typing/events.py | 36 +++++++++++++++---------------- celune/typing/persona.py | 20 ++++++++--------- celune/typing/pipeline.py | 16 +++++++------- celune/ui/app.py | 8 +++---- celune/ui/commands.py | 2 +- celune/ui/terminal.py | 2 +- celune/updater.py | 18 ++++++++-------- celune/utils.py | 2 +- celune/vc_backends/base.py | 2 +- celune/vc_backends/passthrough.py | 2 +- celune/vram.py | 4 ++-- 37 files changed, 131 insertions(+), 132 deletions(-) diff --git a/celune/api.py b/celune/api.py index 2e3d73a..7ea11e1 100644 --- a/celune/api.py +++ b/celune/api.py @@ -9,16 +9,16 @@ 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.middleware.base import RequestResponseEndpoint @@ -31,17 +31,17 @@ 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 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 api = FastAPI(title=f"{APP_NAME}API") bound_celune: Optional[Celune] = None diff --git a/celune/backends/__init__.py b/celune/backends/__init__.py index ca7c68e..a04c4c6 100644 --- a/celune/backends/__init__.py +++ b/celune/backends/__init__.py @@ -1,12 +1,12 @@ # 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 typing import Callable, Union, Optional -from ..typing.backends import BackendModel from .base import CeluneBackend +from ..typing.backends import BackendModel __all__ = ["BackendModel", "CeluneBackend", "get_version", "resolve_backend"] diff --git a/celune/backends/base.py b/celune/backends/base.py index 7e3401b..0f87d0b 100644 --- a/celune/backends/base.py +++ b/celune/backends/base.py @@ -6,9 +6,9 @@ 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 @@ -34,8 +34,8 @@ from ..constants import N_A_NUMERIC from ..cevoice import default_loader from ..exceptions import BackendError -from ..paths import huggingface_hub_cache_dir, temp_data_dir from ..typing.backends import BackendModel, ModelT +from ..paths import huggingface_hub_cache_dir, temp_data_dir __all__ = [ "BackendModel", diff --git a/celune/backends/mini.py b/celune/backends/mini.py index fce98e5..f623fa4 100644 --- a/celune/backends/mini.py +++ b/celune/backends/mini.py @@ -4,8 +4,8 @@ import tempfile import contextlib 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 @@ -15,7 +15,6 @@ 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 diff --git a/celune/celune.py b/celune/celune.py index 6f811a2..80c4c7d 100644 --- a/celune/celune.py +++ b/celune/celune.py @@ -3,14 +3,14 @@ import os import gc -import shutil import time import queue +import shutil import threading import contextlib from pathlib import Path -from typing import Optional, Callable, Protocol, Union, cast from dataclasses import dataclass +from typing import Optional, Callable, Protocol, Union, cast import torch import numpy as np 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/dataclasses/__init__.py b/celune/dataclasses/__init__.py index fd8e199..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 ( diff --git a/celune/dataclasses/celune.py b/celune/dataclasses/celune.py index 33c59e0..4641e84 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,16 +11,17 @@ from transformers.modeling_utils import PreTrainedModel from transformers.tokenization_utils_base import PreTrainedTokenizerBase +from ..config import Config +from ..chroma import AudioRGBGlow +from ..cevoice import CEVoicePersona from ..backends import CeluneBackend +from ..persona.impl import PersonaClient from ..vc_backends import CeluneVCBackend -from ..cevoice import CEVoicePersona -from ..chroma import AudioRGBGlow -from ..config import Config -from ..constants import JSONSerializable, PipelineStates +from ..typing.backends import BackendModel from ..dsp import StreamingPedalboardReverb from ..extensions.manager import CeluneExtensionManager -from ..persona.impl import PersonaClient -from ..typing.backends import BackendModel +from ..constants import JSONSerializable, PipelineStates +from .properties import ConstantPropertySpec, ForwardedPropertySpec from ..typing.celune import ( ErrorCallback, IdleCallback, @@ -31,7 +32,6 @@ VoiceChangedCallback, VoiceLockStateCallback, ) -from .properties import ConstantPropertySpec, ForwardedPropertySpec @dataclass diff --git a/celune/dataclasses/extensions.py b/celune/dataclasses/extensions.py index a48d7de..8bed8a8 100644 --- a/celune/dataclasses/extensions.py +++ b/celune/dataclasses/extensions.py @@ -1,8 +1,8 @@ """Extension-facing dataclasses.""" +from pathlib import Path from dataclasses import dataclass, field from contextlib import AbstractContextManager -from pathlib import Path from typing import TYPE_CHECKING, Optional, Callable, Union from .. import __version__ 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 6aab443..22814d2 100644 --- a/celune/dataclasses/pipeline.py +++ b/celune/dataclasses/pipeline.py @@ -2,10 +2,10 @@ from __future__ import annotations -import queue import time -from dataclasses import dataclass +import queue from pathlib import Path +from dataclasses import dataclass from typing import Optional, Union import numpy as np 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/entrypoint.py b/celune/entrypoint.py index b355a3e..0c4f327 100644 --- a/celune/entrypoint.py +++ b/celune/entrypoint.py @@ -18,10 +18,10 @@ 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 def _env_flag(name: str) -> bool: diff --git a/celune/extensions/base.py b/celune/extensions/base.py index 7075763..0835551 100644 --- a/celune/extensions/base.py +++ b/celune/extensions/base.py @@ -5,8 +5,8 @@ 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): diff --git a/celune/extensions/events.py b/celune/extensions/events.py index 6448be4..ae3764a 100644 --- a/celune/extensions/events.py +++ b/celune/extensions/events.py @@ -4,8 +4,8 @@ from __future__ import annotations import threading -from collections import defaultdict from dataclasses import dataclass +from collections import defaultdict from typing import TYPE_CHECKING, Callable, Literal, Optional, TypeVar, cast, overload from ..typing.events import ( diff --git a/celune/extensions/manager.py b/celune/extensions/manager.py index e7e08e7..6f95a56 100644 --- a/celune/extensions/manager.py +++ b/celune/extensions/manager.py @@ -7,21 +7,21 @@ import traceback import importlib.util from pathlib import Path -from dataclasses import dataclass from types import ModuleType +from dataclasses import dataclass from typing import Callable, Optional, cast -from ..dataclasses.events import ReadyEvent from ..typing.events import EventName +from ..typing.events import EventPayload from ..utils import format_error, discard +from ..dataclasses.events import ReadyEvent from .base import CeluneContext, CeluneExtension +from ..exceptions import InvalidExtensionError, ExtensionAlreadyRegisteredError from .events import ( EventDispatcher, RegisteredEventHandler, iter_subscriptions, ) -from ..typing.events import EventPayload -from ..exceptions import InvalidExtensionError, ExtensionAlreadyRegisteredError @dataclass(frozen=True) diff --git a/celune/paths.py b/celune/paths.py index 312ff48..2772838 100644 --- a/celune/paths.py +++ b/celune/paths.py @@ -1,19 +1,19 @@ # SPDX-License-Identifier: MIT """Runtime filesystem paths and global Hugging Face runtime setup for Celune.""" -import logging import os import sys import shutil +import logging 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 transformers.utils import logging as hf_logging +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" @@ -57,7 +57,7 @@ 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 diff --git a/celune/persona/emotion.py b/celune/persona/emotion.py index 7097407..09f2097 100644 --- a/celune/persona/emotion.py +++ b/celune/persona/emotion.py @@ -10,12 +10,12 @@ import torch import numpy as np import numpy.typing as npt -from transformers import AutoModel, AutoModelForSequenceClassification, AutoTokenizer from transformers.modeling_utils import PreTrainedModel from transformers.tokenization_utils_base import PreTrainedTokenizerBase +from transformers import AutoModel, AutoModelForSequenceClassification, AutoTokenizer -from ..constants import JSONSerializable, PERSONA_EMOTION_MODEL from ..utils import discard +from ..constants import JSONSerializable, PERSONA_EMOTION_MODEL type EmbeddingVector = npt.NDArray[np.float32] diff --git a/celune/persona/runtime.py b/celune/persona/runtime.py index 19ec0e4..5a54a42 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,8 +18,8 @@ BitsAndBytesConfig, ) -from ..utils import discard, normalize_special_characters from ..vram import resolve_vram_preset +from ..utils import discard, normalize_special_characters from ..constants import JSONSerializable, PERSONA_MODEL_ID, N_A_STR from ..dataclasses.persona import ChatMessage, GenerateRequest, GenerateResponse from ..typing.persona import ( diff --git a/celune/pipeline.py b/celune/pipeline.py index f787ed9..42a3b7c 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 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 00095d5..d3a5be5 100644 --- a/celune/typing/celune.py +++ b/celune/typing/celune.py @@ -29,7 +29,7 @@ from ..vc_backends import CeluneVCBackend -GenerationKwarg = Union[torch.Tensor, int, bool, None] +type GenerationKwarg = Union[torch.Tensor, int, bool, None] class SupportsClose(Protocol): @@ -87,7 +87,7 @@ def parameters(self) -> Iterator[torch.nn.Parameter]: raise NotImplementedError("protocol not defined") -ReleasableObject = Union[ +type ReleasableObject = Union[ SupportsClose, SupportsUnload, PreTrainedModel, @@ -173,10 +173,10 @@ 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] class CeluneStateAccessors: 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 index 79cbb4c..b7872e4 100644 --- a/celune/typing/events.py +++ b/celune/typing/events.py @@ -22,7 +22,7 @@ ) -EventName = Literal[ +type EventName = Literal[ "ready", "shutdown", "fatal", @@ -113,22 +113,22 @@ class AudioEventProtocol(CeluneEventProtocol, Protocol): from ..celune import Celune -ReadyEventCallback = Callable[[ReadyEvent], None] -ShutdownEventCallback = Callable[[ShutdownEvent], None] -FatalEventCallback = Callable[[FatalEvent], None] -ErrorEventCallback = Callable[[ErrorEvent], None] -VoiceChangedEventCallback = Callable[[VoiceChangedEvent], None] -StateChangedEventCallback = Callable[[StateChangedEvent], None] -GenerationStartEventCallback = Callable[[GenerationStartEvent], None] -GenerationEndEventCallback = Callable[[GenerationEndEvent], None] -GenerationErrorEventCallback = Callable[[GenerationErrorEvent], None] -AudioStartEventCallback = Callable[[AudioStartEvent], None] -AudioEndEventCallback = Callable[[AudioEndEvent], None] -CharacterChangedEventCallback = Callable[[CharacterChangedEvent], None] -CharacterLoadedEventCallback = Callable[[CharacterLoadedEvent], None] -CharacterUnloadedEventCallback = Callable[[CharacterUnloadedEvent], None] - -EventPayload = Union[ +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, @@ -145,7 +145,7 @@ class AudioEventProtocol(CeluneEventProtocol, Protocol): CharacterUnloadedEvent, ] -EventCallback = Union[ +type EventCallback = Union[ ReadyEventCallback, ShutdownEventCallback, FatalEventCallback, 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 ec3d093..eca39ed 100644 --- a/celune/typing/pipeline.py +++ b/celune/typing/pipeline.py @@ -16,11 +16,11 @@ VoiceConversionRequest, ) -SpeechStreamItem = Optional[Union[npt.NDArray[np.float32], Exception]] -SpeechStreamQueue = queue.Queue[SpeechStreamItem] -TextQueueItem = Union[SpeechRequest, PipelineStates] -AudioInputItem = AudioInputRequest -AudioOutputItem = AudioOutput -VoiceConversionInputItem = VoiceConversionRequest -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 6b06e41..b6a7da7 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -13,29 +13,29 @@ 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, Protocol, Union, TextIO, cast 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 . import resources as ui_resources +from ..constants import APP_NAME, SIGTSTP from .theme import CELUNE_CSS, severity_color from .terminal import LogRedirect, UILogHandler from ..paths import config_path, main_window_log_path diff --git a/celune/ui/commands.py b/celune/ui/commands.py index 7dd1129..c5e468a 100644 --- a/celune/ui/commands.py +++ b/celune/ui/commands.py @@ -14,9 +14,9 @@ from ..paths import project_root from ..constants import APP_NAME from ..backends.qwen3 import Qwen3 -from ..cevoice import active_bundle_path, resolve_bundle_path from ..exceptions import InvalidExtensionError from ..utils import format_error, replace_ipa, format_number +from ..cevoice import active_bundle_path, resolve_bundle_path if TYPE_CHECKING: from .app import CeluneUI diff --git a/celune/ui/terminal.py b/celune/ui/terminal.py index 8862549..692e6fa 100644 --- a/celune/ui/terminal.py +++ b/celune/ui/terminal.py @@ -1,8 +1,8 @@ # SPDX-License-Identifier: MIT """Terminal UI helpers.""" -import sys import re +import sys import logging from typing import Callable, Optional from collections.abc import Collection 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..92a6e10 100644 --- a/celune/utils.py +++ b/celune/utils.py @@ -21,8 +21,8 @@ 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: diff --git a/celune/vc_backends/base.py b/celune/vc_backends/base.py index 4b1b714..fd8d442 100644 --- a/celune/vc_backends/base.py +++ b/celune/vc_backends/base.py @@ -1,8 +1,8 @@ # SPDX-License-Identifier: MIT """Unified voice-conversion backend abstractions for Celune.""" -from abc import ABC, abstractmethod from typing import Callable +from abc import ABC, abstractmethod from ..dataclasses.pipeline import AudioOutput, VoiceConversionRequest diff --git a/celune/vc_backends/passthrough.py b/celune/vc_backends/passthrough.py index 0f2c06f..7a7198a 100644 --- a/celune/vc_backends/passthrough.py +++ b/celune/vc_backends/passthrough.py @@ -3,8 +3,8 @@ import numpy as np -from ..dataclasses.pipeline import AudioOutput, VoiceConversionRequest from .base import CeluneVCBackend +from ..dataclasses.pipeline import AudioOutput, VoiceConversionRequest __all__ = ["CelunePassthroughVCBackend"] diff --git a/celune/vram.py b/celune/vram.py index ab09e94..4396974 100644 --- a/celune/vram.py +++ b/celune/vram.py @@ -3,13 +3,13 @@ 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" From d4b057298409d6f968ba3ab6521f864f87cd6491 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Sun, 21 Jun 2026 21:38:31 +0200 Subject: [PATCH 06/26] chore: align code formatting of persona responses --- celune/persona/prompts.py | 7 +++++-- celune/utils.py | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/celune/persona/prompts.py b/celune/persona/prompts.py index 88f6d66..c1647b3 100644 --- a/celune/persona/prompts.py +++ b/celune/persona/prompts.py @@ -273,9 +273,12 @@ def build(context: PersonaContext) -> str: ), _render_optional_section( "mood", - context.mood_or_state.strip() or "neutral", + context.mood_or_state.strip() or "Target emotion: neutral.", + ), + _render_optional_section( + "behavior", + _render_lines(behavior_lines), ), - f"\n{_render_lines(behavior_lines)}\n".strip(), f"{name}:", ] diff --git a/celune/utils.py b/celune/utils.py index 92a6e10..ad7d749 100644 --- a/celune/utils.py +++ b/celune/utils.py @@ -831,10 +831,10 @@ 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) + return text.translate(special_char_mappings).replace(" ", " ") From 534aad7b012b7724d5e42d96e7c68d00c3eaa5cf Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Mon, 22 Jun 2026 13:21:24 +0200 Subject: [PATCH 07/26] fix: made warnings real warnings and not just bare logs --- README.md | 43 +++++------------------------------- celune/extensions/manager.py | 25 ++++++++++----------- 2 files changed, 17 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 9a1d207..c395e93 100644 --- a/README.md +++ b/README.md @@ -333,39 +333,11 @@ The API allows programmatic usage of all Celune features. It can be used both as ## Extensions -Celune extensions still inherit from `CeluneExtension` and are still loaded through the extension manager from the `extensions` directory. Existing extensions continue to work without modification. +Celune comes with extension support, allowing custom code to run with the engine. -Extensions may now also subscribe to typed engine lifecycle events with `@celune.subscribe(...)`. Decorated handlers are discovered automatically when the extension loads and are removed automatically when the extension unloads. +Extensions may subscribe at will to certain core events with `@celune.subscribe(...)`. -For startup logic, prefer `@celune.subscribe("ready")`. The older `AUTOSTART = True` and `autostart()` hook remains available only as a deprecated compatibility path. - -Any subscribed handler can be opted in or out directly at the decorator site: - -```python -@celune.subscribe("ready", enabled=False) -def on_ready(event) -> None: - ... -``` - -```python -import celune -from celune import CeluneExtension - - -@celune.subscribe("ready") -def on_ready(event) -> None: - event.celune.log("Module-level ready handler.") - - -class DemoExtension(CeluneExtension): - EXTENSION_NAME = "Demo" - - @celune.subscribe("voice_changed") - def on_voice_changed(self, event) -> None: - self.log(f"Voice changed from {event.old_voice} to {event.new_voice}.") -``` - -Available event names: +Celune exposes the following core events to extensions: - `ready` - `shutdown` @@ -382,14 +354,9 @@ Available event names: - `character_loaded` - `character_unloaded` -Typed payload dataclasses live in `celune.dataclasses.events`, and callback aliases plus event-name literals live in `celune.typing.events`. - -Error handling guarantees: +For example extension usage, check the [example extension](./extensions/test.py). -- Extension event callbacks are isolated from the core engine. -- Callback failures are logged as warnings. -- One failing callback does not stop the remaining callbacks. -- Non-required extensions never crash Celune through the event bus. +The aforementioned extension defines a basic usage case for Celune extensions. ## Web UI diff --git a/celune/extensions/manager.py b/celune/extensions/manager.py index 6f95a56..53a6a26 100644 --- a/celune/extensions/manager.py +++ b/celune/extensions/manager.py @@ -3,6 +3,7 @@ import sys import inspect +import warnings import threading import traceback import importlib.util @@ -132,17 +133,16 @@ def autostart_all(self) -> None: ) return - self.context.log( - ( - "[Core] autostart_all() is deprecated. " - "Use @celune.subscribe('ready') instead." - ), - "warning", + warnings.warn( + "CeluneExtensionManager.autostart_all() is deprecated, " + "please use @celune.subscribe('ready') in your extensions instead", + DeprecationWarning, + stacklevel=2, ) started = 0 for name, ext in self.extensions.items(): if self._uses_legacy_autostart(ext): - self.context.log_dev(f"[Core] Running deprecated autostart for: {name}") + self.context.log_dev(f"[Core] Running autostart for: {name}") def runner(e=ext, n=name): try: @@ -312,12 +312,11 @@ def _register_legacy_autostart_handler(self, extension: CeluneExtension) -> None if not self._uses_legacy_autostart(extension): return - self.context.log( - ( - f"[Core] Extension '{extension.name}' uses deprecated autostart() " - "behavior. Please migrate to @celune.subscribe('ready')." - ), - "warning", + warnings.warn( + "CeluneExtension.autostart() is deprecated, " + "please use @celune.subscribe('ready') instead", + DeprecationWarning, + stacklevel=2, ) def legacy_ready_callback( From 53bb99c5dc2b14daa9f29f1e136a8f844a44441f Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Mon, 22 Jun 2026 18:52:55 +0200 Subject: [PATCH 08/26] chore: more UX, do not lock speech during readiness --- celune/constants.py | 10 +++ celune/entrypoint.py | 7 +- celune/pipeline.py | 12 ++++ celune/ui/app.py | 31 ++++++--- celune/ui/terminal.py | 1 + celune/utils.py | 11 +-- launcher.c | 16 ++--- tests/test_pipeline.py | 44 ++++++++++++ tests/test_runtime_and_ui_commands.py | 99 +++++++++++++++++++++++++++ 9 files changed, 208 insertions(+), 23 deletions(-) diff --git a/celune/constants.py b/celune/constants.py index ce3c385..f5a112d 100644 --- a/celune/constants.py +++ b/celune/constants.py @@ -3,6 +3,7 @@ import signal import datetime +import itertools from enum import auto, IntEnum, Enum from .typing.common import JSON as _JSON @@ -141,3 +142,12 @@ class UtteranceLoudnessTier(IntEnum): "high": 12, "xhigh": 16, } + +CRASH_LINES = itertools.cycle( + [ + f"{APP_NAME} has crashed.", + f"Please check {APP_NAME}'s", + "log widget for", + "more information.", + ] +) diff --git a/celune/entrypoint.py b/celune/entrypoint.py index 0c4f327..a725d59 100644 --- a/celune/entrypoint.py +++ b/celune/entrypoint.py @@ -968,6 +968,10 @@ def start(verbose: bool = False) -> None: Exception: Re-raised after printing a traceback in developer mode. """ runtime = _load_runtime() + if runtime.supports_ansi(): + sys.stdout.write(f"\x1b]2;{APP_NAME} is starting up...\x07") + sys.stdout.flush() + try: date = datetime.datetime.now() if runtime.has_name_day("Celine", date) and not runtime.env_bool( @@ -975,9 +979,6 @@ def start(verbose: bool = False) -> None: ): 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}.") diff --git a/celune/pipeline.py b/celune/pipeline.py index 42a3b7c..ec57c4f 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -1886,6 +1886,18 @@ 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 not in {"error", "working"} and engine.cur_state != "error": diff --git a/celune/ui/app.py b/celune/ui/app.py index b6a7da7..3b438f1 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -35,10 +35,10 @@ from ..celune import Celune from ..cevoice import default_loader from . import resources as ui_resources -from ..constants import APP_NAME, SIGTSTP from .theme import CELUNE_CSS, severity_color from .terminal import LogRedirect, UILogHandler from ..paths import config_path, main_window_log_path +from ..constants import APP_NAME, SIGTSTP, CRASH_LINES from .commands import process_command as process_ui_command from ..persona.impl import ( persona_talkback_enabled, @@ -363,7 +363,12 @@ def _refresh_theme_text(self) -> None: class _RefreshableWidget(Protocol): def refresh(self, *args, **kwargs) -> object: - """Refresh one widget in place.""" + """Refresh one widget in place. + + Args: + args: Value for `args`. + kwargs: Value for `kwargs`. + """ def repaint(widget: _RefreshableWidget) -> None: refresh = getattr(widget, "refresh", None) @@ -656,10 +661,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")) @@ -670,8 +672,6 @@ 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", @@ -692,6 +692,16 @@ def _enable_runtime_log_capture(self) -> None: self._install_runtime_log_redirects() 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: @@ -864,6 +874,10 @@ def load_tts(self) -> None: f"New to {APP_NAME}? Type /tutorial to begin the tutorial." ) self._schedule_sleep_timer() + if supports_ansi(self._old_stdout): + self.call_from_thread( + self._write_terminal_escape, f"\x1b]2;{APP_NAME}\x07" + ) else: self.cur_state = "error" self.change_input_state(locked=True) @@ -1507,6 +1521,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: def on_unmount(self) -> None: """Unload Celune.""" + self._write_terminal_escape(f"\x1b]2;{APP_NAME} is exiting...\x07") if self.celune is not None: self.celune.close() diff --git a/celune/ui/terminal.py b/celune/ui/terminal.py index 692e6fa..96ec9fe 100644 --- a/celune/ui/terminal.py +++ b/celune/ui/terminal.py @@ -139,6 +139,7 @@ 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.""" diff --git a/celune/utils.py b/celune/utils.py index ad7d749..31d79a8 100644 --- a/celune/utils.py +++ b/celune/utils.py @@ -14,7 +14,7 @@ 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 @@ -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: diff --git a/launcher.c b/launcher.c index 37518e1..ea52284 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/tests/test_pipeline.py b/tests/test_pipeline.py index 1cc3605..2eb14f5 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -144,6 +144,50 @@ def test_working_signal_completion_does_not_notify_idle(self) -> None: 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. diff --git a/tests/test_runtime_and_ui_commands.py b/tests/test_runtime_and_ui_commands.py index 3661444..9d005d9 100644 --- a/tests/test_runtime_and_ui_commands.py +++ b/tests/test_runtime_and_ui_commands.py @@ -22,6 +22,7 @@ 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 @@ -501,6 +502,104 @@ 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_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), + ), + ) + 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}) From 450251f98011c7e45ff3e413c2308cfcb2ceb4f5 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Tue, 23 Jun 2026 20:11:37 +0200 Subject: [PATCH 09/26] fix: proper TTFC, log redirecting actually works --- celune/analysis.py | 9 +- celune/api.py | 9 +- celune/backends/dotstts.py | 8 + celune/backends/mini.py | 8 + celune/backends/qwen3.py | 7 + celune/backends/voxcpm2.py | 10 +- celune/celune.py | 4 + celune/constants.py | 21 + celune/dataclasses/pipeline.py | 12 +- celune/entrypoint.py | 7 +- celune/extensions/manager.py | 7 + celune/paths.py | 25 +- celune/persona/impl.py | 4 +- celune/persona/runtime.py | 14 +- celune/pipeline.py | 16 +- celune/ui/app.py | 256 +++- celune/ui/terminal.py | 53 +- celune/utils.py | 3 +- extensions/test.py | 5 +- launcher.c | 2 +- nuitka-crash-report.xml | 1782 +++++++++++++++++++++++++ pyproject.toml | 14 +- scripts/build_nuitka.ps1 | 7 + scripts/build_nuitka.sh | 6 + tests/test_backends_and_extensions.py | 21 +- tests/test_celune_core.py | 8 +- tests/test_persona_api.py | 8 + tests/test_pipeline.py | 2 + tests/test_runtime_and_ui_commands.py | 119 +- tests/test_runtime_paths.py | 29 +- uv.lock | 2 +- 31 files changed, 2291 insertions(+), 187 deletions(-) create mode 100644 nuitka-crash-report.xml 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 7ea11e1..699dc90 100644 --- a/celune/api.py +++ b/celune/api.py @@ -21,6 +21,7 @@ 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 ( @@ -1846,7 +1847,9 @@ async def sfx( }, ) - 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={ @@ -1907,7 +1910,9 @@ async def convert_audio( ) try: - output = celune.convert_audio(audio, sample_rate, label=filename) + output = await run_in_threadpool( + celune.convert_audio, audio, sample_rate, label=filename + ) except Exception: return JSONResponse( status_code=500, diff --git a/celune/backends/dotstts.py b/celune/backends/dotstts.py index 25ded35..d2bd6b4 100644 --- a/celune/backends/dotstts.py +++ b/celune/backends/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 @@ -329,8 +330,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 @@ -345,6 +349,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, }, ) @@ -365,6 +370,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, }, ) @@ -379,6 +385,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, }, ) @@ -392,6 +399,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/mini.py index f623fa4..751cacd 100644 --- a/celune/backends/mini.py +++ b/celune/backends/mini.py @@ -3,6 +3,7 @@ import tempfile import contextlib +import time from pathlib import Path from typing import Callable, Optional, cast from collections.abc import Iterator, Mapping @@ -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/qwen3.py index 02c1818..1fa3edf 100644 --- a/celune/backends/qwen3.py +++ b/celune/backends/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 @@ -228,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, @@ -239,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/voxcpm2.py index 4010262..0f7d7d8 100644 --- a/celune/backends/voxcpm2.py +++ b/celune/backends/voxcpm2.py @@ -3,6 +3,7 @@ import os import contextlib +import time from collections.abc import Iterator from typing import Callable, Optional, Mapping, Generator @@ -308,8 +309,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: @@ -325,6 +329,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, }, ) @@ -343,8 +348,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, }, ) @@ -359,6 +365,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, }, @@ -373,6 +380,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/celune.py b/celune/celune.py index 80c4c7d..d92f3bd 100644 --- a/celune/celune.py +++ b/celune/celune.py @@ -497,6 +497,9 @@ def _is_ephemeral_temp_path(path: Path) -> bool: 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() @@ -1797,6 +1800,7 @@ def change_voice(self, voice: str) -> None: 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("Rewarming up...") if not self._warmup(): diff --git a/celune/constants.py b/celune/constants.py index f5a112d..74b7404 100644 --- a/celune/constants.py +++ b/celune/constants.py @@ -5,6 +5,7 @@ import datetime import itertools from enum import auto, IntEnum, Enum +from typing import Optional from .typing.common import JSON as _JSON from .typing.common import JSONSerializable as _JSONSerializable @@ -29,6 +30,7 @@ # 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 @@ -36,8 +38,27 @@ # 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) diff --git a/celune/dataclasses/pipeline.py b/celune/dataclasses/pipeline.py index 22814d2..9e0a725 100644 --- a/celune/dataclasses/pipeline.py +++ b/celune/dataclasses/pipeline.py @@ -95,10 +95,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/entrypoint.py b/celune/entrypoint.py index a725d59..b93cc4c 100644 --- a/celune/entrypoint.py +++ b/celune/entrypoint.py @@ -968,11 +968,10 @@ def start(verbose: bool = False) -> None: Exception: Re-raised after printing a traceback in developer mode. """ runtime = _load_runtime() - if runtime.supports_ansi(): - sys.stdout.write(f"\x1b]2;{APP_NAME} is starting up...\x07") - sys.stdout.flush() - try: + if runtime.supports_ansi(): + sys.stdout.write(f"\x1b]2;{APP_NAME} is starting up...\x07") + sys.stdout.flush() date = datetime.datetime.now() if runtime.has_name_day("Celine", date) and not runtime.env_bool( "CELUNE_OVERRIDE_CELINE_DAY" diff --git a/celune/extensions/manager.py b/celune/extensions/manager.py index 53a6a26..82b452d 100644 --- a/celune/extensions/manager.py +++ b/celune/extensions/manager.py @@ -114,6 +114,9 @@ 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. @@ -385,6 +388,10 @@ def _register_module_handlers( 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__, diff --git a/celune/paths.py b/celune/paths.py index 2772838..b8fda79 100644 --- a/celune/paths.py +++ b/celune/paths.py @@ -4,13 +4,11 @@ import os import sys import shutil -import logging 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 import logging as hf_logging from transformers.utils.logging import disable_progress_bar from .constants import APP_NAME, APP_SLUG @@ -19,7 +17,6 @@ _HF_HOME_ENV = "HF_HOME" _HF_HUB_CACHE_ENV = "HF_HUB_CACHE" _HF_HUB_DISABLE_PROGRESS_BARS_ENV = "HF_HUB_DISABLE_PROGRESS_BARS" -_TRANSFORMERS_CACHE_ENV = "TRANSFORMERS_CACHE" def running_compiled() -> bool: @@ -93,21 +90,6 @@ def huggingface_hub_cache_dir(create: bool = False) -> Path: return path -def transformers_cache_dir(create: bool = False) -> Path: - """Return Celune's default Transformers cache directory. - - Args: - create: Whether this directory should be created before being returned. - - Returns: - Path: Celune's Transformers cache directory. - """ - path = huggingface_home_dir(create=create) / "transformers" - 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. @@ -116,7 +98,6 @@ def configure_huggingface_cache_environment(force: bool = False) -> None: """ default_hf_home = str(huggingface_home_dir()) default_hf_hub_cache = str(huggingface_hub_cache_dir()) - default_transformers_cache = str(transformers_cache_dir()) if not force and not running_compiled(): # If this process previously enabled Celune's portable defaults, @@ -126,8 +107,6 @@ def configure_huggingface_cache_environment(force: bool = False) -> None: 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) - if os.environ.get(_TRANSFORMERS_CACHE_ENV) == default_transformers_cache: - os.environ.pop(_TRANSFORMERS_CACHE_ENV, None) return if _HF_HOME_ENV not in os.environ: @@ -137,12 +116,10 @@ def configure_huggingface_cache_environment(force: bool = False) -> None: def configure_huggingface_runtime() -> None: - """Apply Celune's process-wide Hugging Face logging and progress suppression.""" + """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() - hf_logging.set_verbosity_error() - logging.getLogger("huggingface_hub").setLevel(logging.ERROR) def memory_data_dir(create: bool = False) -> Path: diff --git a/celune/persona/impl.py b/celune/persona/impl.py index 2ad345f..ee0f5da 100644 --- a/celune/persona/impl.py +++ b/celune/persona/impl.py @@ -247,7 +247,9 @@ def uses_default_celune_identity(engine: PersonaEngineView) -> bool: Returns: bool: ``True`` when the default Celune voice bundle is active for Celune. """ - return bool(getattr(engine, "voice_bundle_is_default", False)) + if not bool(getattr(engine, "voice_bundle_is_default", False)): + return False + return persona_active_character_name(engine).strip().casefold() == "celune" def default_persona_persona() -> str: diff --git a/celune/persona/runtime.py b/celune/persona/runtime.py index 5a54a42..87bf33e 100644 --- a/celune/persona/runtime.py +++ b/celune/persona/runtime.py @@ -20,7 +20,12 @@ from ..vram import resolve_vram_preset from ..utils import discard, normalize_special_characters -from ..constants import JSONSerializable, PERSONA_MODEL_ID, N_A_STR +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 ec57c4f..2c7dfd5 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -100,6 +100,7 @@ ) from .typing.pipeline import SpeechStreamQueue + if TYPE_CHECKING: from .celune import Celune @@ -1472,7 +1473,12 @@ def convert_audio_input( if loader is not None: try: target_references = (loader.materialize(current_voice, "wav"),) - except Exception: + except Exception as e: + engine.log( + "Could not load reference audio for voice conversion:" + f"{format_error(e, getattr(engine, 'dev', False))}" + "warning" + ) target_references = () return backend.convert( @@ -2097,7 +2103,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() diff --git a/celune/ui/app.py b/celune/ui/app.py index 3b438f1..08603ad 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -36,7 +36,7 @@ from ..cevoice import default_loader from . import resources as ui_resources 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 .commands import process_command as process_ui_command @@ -112,12 +112,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 @@ -233,17 +239,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" + ) + _original_dunder_stdout = _forward_ui_property( + "_log_capture_state", "original_dunder_stdout" + ) + _original_dunder_stderr = _forward_ui_property( + "_log_capture_state", "original_dunder_stderr" ) - _runtime_redirect_handlers = _forward_ui_property( - "_log_capture_state", "runtime_redirect_handlers" + _stderr_pipe_read_fd = _forward_ui_property( + "_log_capture_state", "stderr_pipe_read_fd" ) - _runtime_redirect_original_handlers = _forward_ui_property( - "_log_capture_state", "runtime_redirect_original_handlers" + _stderr_pipe_write_fd = _forward_ui_property( + "_log_capture_state", "stderr_pipe_write_fd" ) - _runtime_redirect_original_propagate = _forward_ui_property( - "_log_capture_state", "runtime_redirect_original_propagate" + _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" @@ -640,7 +664,7 @@ def on_mount(self) -> None: self._refresh_status() self._refresh_theme_text() self._refresh_logs() - self._install_runtime_log_redirects() + self._enable_runtime_log_capture() ui_resources.prime_usage() self.set_interval(2.06, self.advance_resources) self._status_marquee_timer = self.set_interval( @@ -689,7 +713,9 @@ def _enable_runtime_log_capture(self) -> None: 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: @@ -703,60 +729,175 @@ def _write_terminal_escape(self, escape: str) -> None: 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 = {} + handler = UILogHandler(self.safe_log) + original_call_handlers = logging.Logger.callHandlers - for logger_name in ( - "torch.utils.flop_counter", - "py.warnings", - "huggingface_hub", - "transformers", - ): - 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 + 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.""" @@ -765,7 +906,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 @@ -869,7 +1012,6 @@ 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." ) diff --git a/celune/ui/terminal.py b/celune/ui/terminal.py index 96ec9fe..a3cc047 100644 --- a/celune/ui/terminal.py +++ b/celune/ui/terminal.py @@ -1,9 +1,9 @@ # SPDX-License-Identifier: MIT """Terminal UI helpers.""" +import logging import re import sys -import logging from typing import Callable, Optional from collections.abc import Collection @@ -94,6 +94,34 @@ def __init__( filter_messages # these messages will be filtered out by the logger ) + @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. @@ -124,7 +152,10 @@ def write(self, text: str) -> None: self._buffer = self._buffer[pos + 1 :] if chunk: - self.write_callback(chunk, self.default_severity) + 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. @@ -144,7 +175,11 @@ def ansi(self, escape: str) -> None: def flush(self) -> None: """Flush the buffers.""" if self._buffer.strip(): - self.write_callback(self._buffer.strip(), self.default_severity) + chunk = self._buffer.strip() + self.write_callback( + chunk, + self._severity_for_message(chunk, self.default_severity), + ) self._buffer = "" def isatty(self) -> bool: @@ -195,3 +230,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/utils.py b/celune/utils.py index 31d79a8..51d0d00 100644 --- a/celune/utils.py +++ b/celune/utils.py @@ -840,4 +840,5 @@ def normalize_special_characters(text: str) -> str: } ) - return text.translate(special_char_mappings).replace(" ", " ") + normalized = text.translate(special_char_mappings) + return re.sub(r"\s{2,}", " ", normalized) diff --git a/extensions/test.py b/extensions/test.py index 22068ba..1d8a32b 100644 --- a/extensions/test.py +++ b/extensions/test.py @@ -6,6 +6,7 @@ import celune from celune import CeluneExtension +from celune.dataclasses.events import ReadyEvent, VoiceChangedEvent from celune.utils import discard @@ -15,7 +16,7 @@ class TestExtension(CeluneExtension): EXTENSION_NAME = "Test" @celune.subscribe("ready", enabled=False) - def on_ready(self, event) -> None: + def on_ready(self, event: ReadyEvent) -> None: """Demonstrate extension behavior when Celune becomes ready. Args: @@ -49,7 +50,7 @@ def on_ready(self, event) -> None: self.say("You will only hear this once.", save=False) @celune.subscribe("voice_changed") - def on_voice_changed(self, event) -> None: + def on_voice_changed(self, event: VoiceChangedEvent) -> None: """Demonstrate access to typed voice-change event payloads. Args: diff --git a/launcher.c b/launcher.c index ea52284..6594d41 100644 --- a/launcher.c +++ b/launcher.c @@ -684,7 +684,7 @@ 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; } diff --git a/nuitka-crash-report.xml b/nuitka-crash-report.xml new file mode 100644 index 0000000..2020ecd --- /dev/null +++ b/nuitka-crash-report.xml @@ -0,0 +1,1782 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index d592ca2..4613369 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" @@ -100,16 +100,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] @@ -122,8 +126,4 @@ api = [ ] [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", -] +filterwarnings = "ignore::DeprecationWarning" diff --git a/scripts/build_nuitka.ps1 b/scripts/build_nuitka.ps1 index e71da65..4fe37cc 100644 --- a/scripts/build_nuitka.ps1 +++ b/scripts/build_nuitka.ps1 @@ -8,10 +8,17 @@ $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 +$existingProcess = Get-Process celune -ErrorAction SilentlyContinue $env:CL = "/O2 /GL /GS /guard:cf /DNDEBUG" $env:_CL_ = "/link /LTCG /OPT:REF /OPT:ICF /DYNAMICBASE /NXCOMPAT" +if ($null -ne $existingProcess) { + Write-Host "Celune is already running, terminating before proceeding with build." + Stop-Process -Name celune -Force + Stop-Process -Name celune-bin -Force +} + 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..c1566c2 100755 --- a/scripts/build_nuitka.sh +++ b/scripts/build_nuitka.sh @@ -11,6 +11,12 @@ app_dir="$output_dir/Celune.AppDir" desktop_src="$repo_root/Celune.AppDir/celune.desktop" icon_src="$repo_root/Celune.AppDir/celune.png" +if pgrep celune &>/dev/null; then + echo "Celune is already running, terminating before proceeding with build." + pkill -9 celune + pkill -9 celune-bin +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/tests/test_backends_and_extensions.py b/tests/test_backends_and_extensions.py index c070d19..73d1312 100644 --- a/tests/test_backends_and_extensions.py +++ b/tests/test_backends_and_extensions.py @@ -953,6 +953,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, ...]]] = [] @@ -969,12 +980,8 @@ def setUp(self) -> None: set_voice=lambda name: True, get_state=lambda: "idle", wait_until_ready=lambda timeout=30.0: True, - backend_override=lambda backend_name: contextlib.nullcontext( - cast(Celune, SimpleNamespace()) - ), - cevoice_override=lambda bundle: contextlib.nullcontext( - cast(Celune, SimpleNamespace()) - ), + backend_override=self.backend_override, + cevoice_override=self.cevoice_override, ) def test_context_and_extension_helpers_delegate_calls(self) -> None: @@ -1000,6 +1007,8 @@ def test_context_and_extension_helpers_delegate_calls(self) -> None: 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_celune_core.py b/tests/test_celune_core.py index 1755140..1639781 100644 --- a/tests/test_celune_core.py +++ b/tests/test_celune_core.py @@ -908,10 +908,12 @@ def test_set_backend_marks_reload_pending_before_playing_working_signal( 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]] = [] + 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)) + 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) @@ -919,7 +921,7 @@ def record_signal(signal_type: str) -> bool: with mock.patch("celune.celune.threading.Thread") as thread_cls: self.assertEqual(celune.set_backend("mini"), True) - self.assertEqual(signal_states, [("working", "idle", 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") 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_pipeline.py b/tests/test_pipeline.py index 2eb14f5..e10cbc4 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1430,6 +1430,8 @@ def test_default_celune_prompt_uses_canonical_age_and_gender(self) -> None: 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.""" diff --git a/tests/test_runtime_and_ui_commands.py b/tests/test_runtime_and_ui_commands.py index 9d005d9..7a9e771 100644 --- a/tests/test_runtime_and_ui_commands.py +++ b/tests/test_runtime_and_ui_commands.py @@ -433,8 +433,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(), @@ -450,31 +450,36 @@ def test_textual_ui_delays_stdio_redirects_until_runtime_is_ready(self) -> None: 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_runtime_log_capture_restores_stdio_after_shutdown(self) -> None: """Verify explicit runtime capture swaps and restores stdio cleanly.""" @@ -553,6 +558,32 @@ def test_log_redirect_ansi_forwards_and_flushes_underlying_stdout(self) -> None: 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_load_tts_writes_terminal_title_to_original_stdout(self) -> None: """Verify the ready-state title reset targets the original terminal stream.""" ui = CeluneUI() @@ -822,16 +853,12 @@ def test_placeholder_uses_loaded_persona_not_runtime_capability(self) -> None: self.assertEqual(ui.normal_input_placeholder(), "Say something...") 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) @@ -858,11 +885,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) @@ -889,11 +911,6 @@ def test_runtime_huggingface_logger_error_is_routed_into_ui_logs(self) -> None: ui.safe_log = lambda msg, severity="info": captured.append((msg, severity)) logger = logging.getLogger("huggingface_hub") - 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) @@ -910,6 +927,26 @@ def test_runtime_huggingface_logger_error_is_routed_into_ui_logs(self) -> None: ], ) + 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_safe_status_marquees_long_text_for_narrow_status_label(self) -> None: """Verify long status text scrolls instead of clipping.""" diff --git a/tests/test_runtime_paths.py b/tests/test_runtime_paths.py index 672e8ca..126ca0f 100644 --- a/tests/test_runtime_paths.py +++ b/tests/test_runtime_paths.py @@ -1,7 +1,6 @@ # SPDX-License-Identifier: MIT """Tests for Celune runtime path handling.""" -import logging import tempfile import sys import os @@ -21,7 +20,6 @@ huggingface_hub_cache_dir, project_root, running_compiled, - transformers_cache_dir, ) from celune.persona.memory import default_memory_dir from celune.cevoice import bundled_voices_dir, default_bundle_path @@ -80,10 +78,6 @@ def test_huggingface_cache_dirs_live_in_runtime_data(self) -> None: huggingface_hub_cache_dir(), expected_root / "huggingface" / "hub", ) - self.assertEqual( - transformers_cache_dir(), - expected_root / "huggingface" / "transformers", - ) def test_huggingface_cache_environment_defaults_to_runtime_data(self) -> None: """Verify Celune points Hugging Face caches at the runtime data directory.""" @@ -144,9 +138,6 @@ def test_huggingface_cache_environment_clears_celune_portable_defaults( { "HF_HOME": str(expected_root / "huggingface"), "HF_HUB_CACHE": str(expected_root / "huggingface" / "hub"), - "TRANSFORMERS_CACHE": str( - expected_root / "huggingface" / "transformers" - ), }, clear=True, ), @@ -154,7 +145,6 @@ def test_huggingface_cache_environment_clears_celune_portable_defaults( configure_huggingface_cache_environment() self.assertNotIn("HF_HOME", os.environ) self.assertNotIn("HF_HUB_CACHE", os.environ) - self.assertNotIn("TRANSFORMERS_CACHE", os.environ) def test_huggingface_cache_environment_keeps_non_celune_overrides( self, @@ -163,7 +153,6 @@ def test_huggingface_cache_environment_keeps_non_celune_overrides( existing = { "HF_HOME": "X:/hf-home", "HF_HUB_CACHE": "X:/hf-hub", - "TRANSFORMERS_CACHE": "X:/legacy-transformers-cache", } with ( @@ -173,31 +162,19 @@ def test_huggingface_cache_environment_keeps_non_celune_overrides( configure_huggingface_cache_environment() self.assertEqual(os.environ["HF_HOME"], existing["HF_HOME"]) self.assertEqual(os.environ["HF_HUB_CACHE"], existing["HF_HUB_CACHE"]) - self.assertEqual( - os.environ["TRANSFORMERS_CACHE"], - existing["TRANSFORMERS_CACHE"], - ) - def test_huggingface_runtime_disables_global_progress_and_logs(self) -> None: - """Verify Celune globally suppresses Hugging Face progress and log noise.""" + 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("celune.paths.hf_logging.set_verbosity_error") as set_verbosity, - mock.patch("celune.paths.logging.getLogger") as get_logger, mock.patch.dict(os.environ, {}, clear=True), ): - logger = mock.Mock() - get_logger.return_value = logger - configure_huggingface_runtime() + self.assertEqual(os.environ["HF_HUB_DISABLE_PROGRESS_BARS"], "1") - self.assertEqual(os.environ["HF_HUB_DISABLE_PROGRESS_BARS"], "1") disable_transformers.assert_called_once_with() disable_hub.assert_called_once_with() - set_verbosity.assert_called_once_with() - get_logger.assert_called_once_with("huggingface_hub") - logger.setLevel.assert_called_once_with(logging.ERROR) 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..bca9353 100644 --- a/uv.lock +++ b/uv.lock @@ -330,7 +330,7 @@ wheels = [ [[package]] name = "celune" -version = "4.1.0" +version = "4.2.0" source = { virtual = "." } dependencies = [ { name = "accelerate" }, From 705e43954eb93fcb367b9742a3388f2c75e58796 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Tue, 23 Jun 2026 20:12:06 +0200 Subject: [PATCH 10/26] chore: we dont need that --- nuitka-crash-report.xml | 1782 --------------------------------------- 1 file changed, 1782 deletions(-) delete mode 100644 nuitka-crash-report.xml diff --git a/nuitka-crash-report.xml b/nuitka-crash-report.xml deleted file mode 100644 index 2020ecd..0000000 --- a/nuitka-crash-report.xml +++ /dev/null @@ -1,1782 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 3e89cbcb2803042d52b57c581c5ac72bd53429f3 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Wed, 24 Jun 2026 15:26:00 +0200 Subject: [PATCH 11/26] chore: fix final bits of @coderabbitai warnings --- pyproject.toml | 8 +++++++- scripts/build_nuitka.ps1 | 7 +++---- scripts/build_nuitka.sh | 9 ++++++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4613369..d173acf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,4 +126,10 @@ api = [ ] [tool.pytest.ini_options] -filterwarnings = "ignore::DeprecationWarning" +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", + "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/scripts/build_nuitka.ps1 b/scripts/build_nuitka.ps1 index 4fe37cc..26851e6 100644 --- a/scripts/build_nuitka.ps1 +++ b/scripts/build_nuitka.ps1 @@ -8,15 +8,14 @@ $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 -$existingProcess = Get-Process celune -ErrorAction SilentlyContinue +$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 $existingProcess) { ++if ($null -ne $existingProcesses) { Write-Host "Celune is already running, terminating before proceeding with build." - Stop-Process -Name celune -Force - Stop-Process -Name celune-bin -Force + $existingProcesses | Stop-Process -Force -ErrorAction SilentlyContinue } if ($null -eq $projectVersion) { diff --git a/scripts/build_nuitka.sh b/scripts/build_nuitka.sh index c1566c2..7873c61 100755 --- a/scripts/build_nuitka.sh +++ b/scripts/build_nuitka.sh @@ -11,10 +11,13 @@ app_dir="$output_dir/Celune.AppDir" desktop_src="$repo_root/Celune.AppDir/celune.desktop" icon_src="$repo_root/Celune.AppDir/celune.png" -if pgrep celune &>/dev/null; then +if pgrep -x celune >/dev/null || pgrep -x celune-bin >/dev/null; then echo "Celune is already running, terminating before proceeding with build." - pkill -9 celune - pkill -9 celune-bin + 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")" From 8708821a200948fa39f321f409a99154cfd1d399 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Wed, 24 Jun 2026 15:35:35 +0200 Subject: [PATCH 12/26] fix: fix this and fast --- scripts/build_nuitka.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build_nuitka.ps1 b/scripts/build_nuitka.ps1 index 26851e6..92803ae 100644 --- a/scripts/build_nuitka.ps1 +++ b/scripts/build_nuitka.ps1 @@ -13,7 +13,7 @@ $existingProcesses = Get-Process -Name @("celune", "celune-bin") -ErrorAction Si $env:CL = "/O2 /GL /GS /guard:cf /DNDEBUG" $env:_CL_ = "/link /LTCG /OPT:REF /OPT:ICF /DYNAMICBASE /NXCOMPAT" -+if ($null -ne $existingProcesses) { +if ($null -ne $existingProcesses) { Write-Host "Celune is already running, terminating before proceeding with build." $existingProcesses | Stop-Process -Force -ErrorAction SilentlyContinue } From fc5b5367b18d2b69653ceade18abfd92c9fea5e4 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Wed, 24 Jun 2026 18:59:15 +0200 Subject: [PATCH 13/26] refactor: i18n support --- celune/api.py | 156 ++++++---- celune/celune.py | 317 +++++++++++-------- celune/entrypoint.py | 291 ++++++++++-------- celune/extensions/manager.py | 55 ++-- celune/i18n.py | 462 +++++++++++++++++++++++++++- celune/pipeline.py | 205 +++++++----- celune/ui/app.py | 31 +- celune/ui/commands.py | 256 +++++++-------- celune/ui/headless.py | 11 +- tests/test_namedays_i18n_updater.py | 15 + tests/test_pipeline.py | 2 +- 11 files changed, 1246 insertions(+), 555 deletions(-) diff --git a/celune/api.py b/celune/api.py index 699dc90..036d831 100644 --- a/celune/api.py +++ b/celune/api.py @@ -43,6 +43,7 @@ from .ui import resources as ui_resources 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 @@ -62,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" @@ -545,7 +546,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"}, ) @@ -561,7 +562,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)}, ) @@ -586,11 +587,11 @@ def bind_celune(celune: Celune) -> None: 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" + string("webui.tutorial_placeholder") if celune.is_in_tutorial - else "Please wait" + else string("webui.wait_placeholder") if celune and webui_input_locked - else "Enter text to speak here" + else string("webui.input_placeholder") ) webui_voice_locked = ( len(celune.voices) < 2 or celune.is_in_tutorial or not has_voice @@ -598,9 +599,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", ) @@ -692,20 +695,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: @@ -721,13 +727,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"} @@ -774,7 +780,7 @@ 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: @@ -782,11 +788,11 @@ def wrapped_input_state(locked: bool) -> None: 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" + string("webui.tutorial_placeholder") if celune.is_in_tutorial - else "Please wait" + else string("webui.wait_placeholder") if celune and webui_input_locked - else "Enter text to speak here" + else string("webui.input_placeholder") ) original_input_state(locked) @@ -829,7 +835,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 @@ -1070,22 +1076,22 @@ 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): @@ -1094,9 +1100,9 @@ def _input_update( else: interactive = not celune.locked and has_voice placeholder = ( - "Please wait" + string("webui.wait_placeholder") if celune and (celune.locked or not has_voice) - else "Enter text to speak here" + else string("webui.input_placeholder") ) if has_value: return gr.update( @@ -1179,7 +1185,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 @@ -1187,7 +1193,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: @@ -1213,7 +1219,7 @@ def _voice_conversion_unavailable_response() -> JSONResponse: status_code=409, content={ "error": "wrong_mode", - "message": "I am not currently able to do this.", + "message": string("webui.wrong_mode"), }, ) @@ -1250,14 +1256,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(): @@ -1267,7 +1273,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 @@ -1295,7 +1301,7 @@ 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("") @@ -1316,7 +1322,7 @@ def _webui_convert_audio( """Convert uploaded audio through the active VC backend for browser playback.""" if source_audio is None: _append_webui_log( - "Upload or record audio before starting voice conversion.", + string("webui.upload_audio_first"), "warning", ) logs_html, status_html, resources_html, voice_update, send_update, _input = ( @@ -1335,7 +1341,7 @@ def _webui_convert_audio( celune = require_celune() if getattr(celune, "input_mode", "text_to_speech") != "voice_conversion": _append_webui_log( - "Audio conversion is only available in voice conversion mode.", + string("webui.conversion_only_in_vc_mode"), "warning", ) logs_html, status_html, resources_html, voice_update, send_update, _input = ( @@ -1357,7 +1363,7 @@ def _webui_convert_audio( output = celune.convert_audio(audio, sample_rate, label="browser audio input") 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", ) logs_html, status_html, resources_html, voice_update, send_update, _input = ( @@ -1374,7 +1380,7 @@ def _webui_convert_audio( ) if output is None: - _append_webui_log("I can't convert that right now.", "warning") + _append_webui_log(string("webui.cannot_convert_right_now"), "warning") logs_html, status_html, resources_html, voice_update, send_update, _input = ( _webui_snapshot() ) @@ -1435,7 +1441,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() @@ -1467,7 +1473,7 @@ def _build_webui() -> gr.Blocks: lines=1, max_lines=4, show_label=False, - placeholder="Please wait", + placeholder=string("webui.wait_placeholder"), container=False, elem_id="celune-input", scale=8, @@ -1475,14 +1481,14 @@ def _build_webui() -> gr.Blocks: ) with gr.Row(elem_id="celune-actions", scale=2): voice_button = gr.Button( - value="Balanced", + value=string("webui.default_voice_button"), elem_id="celune-style", scale=1, min_width=0, interactive=False, ) send_button = gr.Button( - value="Send", + value=string("webui.send_button"), elem_id="celune-send", scale=1, min_width=0, @@ -1497,7 +1503,7 @@ def _build_webui() -> gr.Blocks: gr.HTML( textwrap.dedent(f"""

- Usage may differ. Some {APP_NAME} features may not be available. + {string("webui.features_may_differ", app_name=APP_NAME)}

""") ) @@ -1662,7 +1668,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"), }, ) @@ -1691,7 +1697,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"), }, ) @@ -1730,7 +1736,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"), }, ) @@ -1753,7 +1759,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"), }, ) @@ -1790,7 +1796,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"), }, ) @@ -1799,7 +1805,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"), }, ) @@ -1831,7 +1837,7 @@ 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"), }, ) @@ -1843,7 +1849,7 @@ async def sfx( status_code=400, content={ "error": "invalid_audio", - "message": "I don't understand your input.", + "message": string("api.invalid_input"), }, ) @@ -1854,7 +1860,7 @@ async def sfx( status_code=409, content={ "error": "not_ready", - "message": "I can't play that right now.", + "message": string("api.cannot_play_now"), }, ) @@ -1894,7 +1900,7 @@ async def convert_audio( status_code=413, content={ "error": "request_too_large", - "message": "That source audio is too large for me to convert.", + "message": string("api.source_audio_too_large"), }, ) @@ -1905,7 +1911,7 @@ async def convert_audio( status_code=400, content={ "error": "invalid_audio", - "message": "I don't understand your input.", + "message": string("api.invalid_input"), }, ) @@ -1918,7 +1924,7 @@ async def convert_audio( status_code=500, content={ "error": "request_failed", - "message": "I couldn't convert that.", + "message": string("api.could_not_convert"), }, ) @@ -1927,7 +1933,7 @@ async def convert_audio( status_code=409, content={ "error": "not_ready", - "message": "I can't convert that.", + "message": string("api.cannot_convert"), }, ) @@ -1981,8 +1987,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: @@ -2033,8 +2043,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: @@ -2051,11 +2067,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") @@ -2066,7 +2086,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/celune.py b/celune/celune.py index d92f3bd..3df990b 100644 --- a/celune/celune.py +++ b/celune/celune.py @@ -57,6 +57,7 @@ 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 ( CeluneStateAccessors, Generative, @@ -511,13 +512,20 @@ def _cleanup_residual_temp_data(self, temp_dir: Path) -> None: return if trailing_files == 1: - self.log(f"{APP_NAME} found a residual temporary item.", "warning") + self.log( + string("celune.residual_temp_item", app_name=APP_NAME), + "warning", + ) else: self.log( - f"{APP_NAME} found {trailing_files} residual temporary items.", + string( + "celune.residual_temp_items", + app_name=APP_NAME, + count=trailing_files, + ), "warning", ) - self.log("Deleting...", "warning") + self.log(string("celune.deleting"), "warning") with contextlib.suppress(OSError): for path in disposable_paths: @@ -617,7 +625,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) @@ -867,9 +875,15 @@ def _hot_reload_backend( snapshot = self._capture_reload_snapshot() preset = resolve_vram_preset(self.config) candidate_kwargs = self._backend_reload_kwargs(backend_spec) - self.log(f"{APP_NAME} is switching to {requested_name}, please wait...") + self.log( + string( + "celune.switching_backend", + app_name=APP_NAME, + backend=requested_name, + ) + ) self._ready_announced = False - self.status_callback("Reloading backend") + self.status_callback(string("status.reloading_backend")) self.progress_callback(None, None) self.cur_state = "reloading" @@ -946,17 +960,17 @@ def _hot_reload_backend( new_voice=candidate_voice, ), ) - self.log(f"Switched backend to {requested_name}.") + self.log(string("celune.switched_backend", backend=requested_name)) self.progress_callback(1, 1) self.cur_state = "idle" - self.status_callback("Idle") + self.status_callback(string("status.idle")) return True except Exception as e: self.log( - f"[RELOAD ERROR] {format_error(e, self.dev)}", + string("celune.reload_error", error=format_error(e, self.dev)), "error", ) - self.status_callback("Restoring backend") + self.status_callback(string("status.restoring_backend")) self.progress_callback(None, None) if ( candidate_backend is not None @@ -980,10 +994,10 @@ def _hot_reload_backend( else: self.cur_state = "idle" self._last_warmup_error = None - self.status_callback("Idle") + self.status_callback(string("status.idle")) self.progress_callback(1, 1) self.log( - "Could not load this backend. The previous backend was restored.", + string("celune.backend_restore_failed"), "warning", ) return False @@ -1011,9 +1025,9 @@ def _hot_reload_cevoice( if previous_loader is not None else active_bundle_path() ) - self.log(f"{APP_NAME} is reloading the character, please stand by...") + self.log(string("celune.reloading_character", app_name=APP_NAME)) self._ready_announced = False - self.status_callback("Reloading character") + self.status_callback(string("status.reloading_character")) self.progress_callback(None, None) self.cur_state = "reloading" @@ -1070,15 +1084,18 @@ def _hot_reload_cevoice( ), ) self.log( - f"Switched to character: {bundle if bundle is not None else previous_bundle.name}" + 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("Idle") + self.status_callback(string("status.idle")) return True except Exception as e: self.log( - f"[RELOAD ERROR] {format_error(e, self.dev)}", + string("celune.reload_error", error=format_error(e, self.dev)), "error", ) if ( @@ -1092,10 +1109,10 @@ def _hot_reload_cevoice( self._restore_reload_snapshot(snapshot) else: self.cur_state = "idle" - self.status_callback("Idle") + self.status_callback(string("status.idle")) self.progress_callback(1, 1) self.log( - "Could not switch to this character. The previous character was restored.", + string("celune.character_restore_failed"), "warning", ) return False @@ -1232,7 +1249,7 @@ 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" @@ -1277,8 +1294,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 @@ -1289,21 +1309,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() @@ -1421,13 +1446,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() @@ -1454,7 +1479,7 @@ 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 switching voice.", "warning") + self.log(string("celune.voice_switch_timeout"), "warning") return False return self.loaded and self.current_voice == name @@ -1471,14 +1496,17 @@ def set_backend( bool: ``True`` when the reload worker was started. """ if self._reload_pending or self.cur_state == "reloading": - self.log("A backend or character reload is already in progress.", "warning") + 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: self.log( - f"Unknown backend: {backend_spec} " - f"(available: {', '.join(BACKENDS.keys())})", + string( + "celune.unknown_backend", + backend=backend_spec, + available=", ".join(BACKENDS.keys()), + ), "warning", ) return False @@ -1516,7 +1544,7 @@ def set_backend_and_wait( return False if not self._model_ready.wait(timeout=timeout): - self.log("Timed out while switching backends.", "warning") + self.log(string("celune.backend_switch_timeout"), "warning") return False target_name = ( @@ -1536,13 +1564,15 @@ def set_cevoice(self, bundle: Optional[Union[str, Path]]) -> bool: bool: ``True`` when the reload worker was started. """ if self._reload_pending or self.cur_state == "reloading": - self.log("A backend or character reload is already in progress.", "warning") + 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(f"Voice pack not found: {bundle}", "warning") + self.log( + string("celune.voice_pack_not_found", bundle=bundle), "warning" + ) return False self.change_input_state_callback(locked=True) @@ -1576,7 +1606,7 @@ def set_cevoice_and_wait( return False if not self._model_ready.wait(timeout=timeout): - self.log("Timed out while switching characters.", "warning") + self.log(string("celune.character_switch_timeout"), "warning") return False return self.loaded and active_bundle_path() == resolve_bundle_path(bundle) @@ -1648,24 +1678,19 @@ def _wait_until_idle(self, timeout: float = 30.0) -> bool: # 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 @@ -1775,9 +1800,9 @@ 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 @@ -1802,7 +1827,7 @@ def change_voice(self, voice: str) -> None: self.model = self.backend.load_model(new_model_name) self.model_name = new_model_name - self.log("Rewarming up...") + self.log(string("celune.rewarming_up")) if not self._warmup(): self._raise_warmup_error("warmup failed after reload") @@ -1828,20 +1853,26 @@ def change_voice(self, voice: str) -> None: new_voice=voice, ), ) - self.log(f"Voice {voice} loaded.") + 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) @@ -1865,12 +1896,12 @@ def load(self) -> bool: 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: @@ -1879,9 +1910,11 @@ def load(self) -> bool: self.current_character = character if self.voice_bundle_is_default: - self.log(f"Current character: {character} (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() @@ -1890,59 +1923,65 @@ 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) if self._is_voice_conversion_mode(): if self.vc_backend is None: self.cur_state = "error" - self.log("No voice conversion backend is available.", "error") + self.log(string("celune.no_vc_backend"), "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 valid voice conversion backend") + self.error_callback(string("celune.no_valid_vc_backend")) return False self.vc_backend.preload_models() self.model = None self.model_name = "" - self.log("Ready to accept voice conversions.") + self.log(string("celune.ready_for_vc")) else: self.backend.preload_models() - self.log("All voices are available.") + 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(f"{APP_NAME} could not load the default model.", "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("Could not play the error signal.", "warning") + self.log_dev(string("ui.error_signal_unavailable"), "warning") self.progress_callback(0, 1) - self.error_callback("Default model failed to load") + 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")) playback_thread = threading.Thread(target=self._playback_worker, daemon=True) @@ -1971,7 +2010,7 @@ def load(self) -> bool: 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 warmup_ok = True @@ -1985,10 +2024,10 @@ def load(self) -> bool: 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: @@ -1998,7 +2037,7 @@ def load(self) -> bool: 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", ) @@ -2029,7 +2068,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 @@ -2039,14 +2078,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 @@ -2059,7 +2098,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 @@ -2075,22 +2118,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: @@ -2103,9 +2152,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: @@ -2142,12 +2192,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: @@ -2158,8 +2214,11 @@ 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( @@ -2170,8 +2229,8 @@ def _warmup( 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 @@ -2215,14 +2274,19 @@ def _warmup( return True except Exception as e: self._last_warmup_error = e - self.log(f"[WARMUP ERROR] {format_error(e, self.dev)}", "error") + self.log( + string("celune.warmup_error", error=format_error(e, self.dev)), + "error", + ) self.progress_callback(0, 1) if fatal_on_failure: 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.error_callback(f"{APP_NAME} could not warm up") + 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 @@ -2281,9 +2345,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(): @@ -2302,7 +2366,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) @@ -2317,18 +2381,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 @@ -2356,26 +2428,29 @@ def think( bool: ``True`` if Celune processed this smart request, otherwise ``False``. """ if self.input_mode != "text_to_speech": - self.log("Text input is unavailable in voice conversion mode.", "warning") - self.error_callback("Text input is unavailable in voice conversion mode") + self.log(string("celune.text_input_unavailable_vc"), "warning") + self.error_callback(string("celune.text_input_unavailable_vc")) 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 @@ -2398,7 +2473,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( @@ -2418,8 +2493,8 @@ def say( bool: ``True`` when the text was queued successfully, otherwise ``False``. """ if self.input_mode != "text_to_speech": - self.log("Text input is unavailable in voice conversion mode.", "warning") - self.error_callback("Text input is unavailable in voice conversion mode") + self.log(string("celune.text_input_unavailable_vc"), "warning") + self.error_callback(string("celune.text_input_unavailable_vc")) self.progress_callback(0, 1) return False @@ -2484,10 +2559,10 @@ def convert_audio( """ if not self._is_voice_conversion_mode(): self.log( - "Audio conversion is unavailable outside voice conversion mode.", + string("celune.audio_conversion_unavailable"), "warning", ) - self.error_callback("Not possible") + self.error_callback(string("celune.not_possible")) self.progress_callback(0, 1) return None diff --git a/celune/entrypoint.py b/celune/entrypoint.py index b93cc4c..c05831d 100644 --- a/celune/entrypoint.py +++ b/celune/entrypoint.py @@ -22,6 +22,7 @@ from celune import __version__, REVISION, __tagline__ from celune.paths import project_root, running_compiled from celune.constants import APP_NAME, APP_SLUG, ExitCodes +from celune.i18n import string def _env_flag(name: str) -> bool: @@ -67,25 +68,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 +837,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 +854,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 +865,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 +896,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,47 +915,47 @@ 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) @@ -980,8 +982,8 @@ def start(verbose: bool = False) -> None: 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 +993,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 +1009,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 +1022,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 +1050,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 +1115,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 +1149,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 +1171,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 +1179,39 @@ def start(verbose: bool = False) -> None: install() raise - print(exc or "no error description") - print("For full traceback:") + print(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,30 +1232,36 @@ 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.") + 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) @@ -1241,45 +1270,41 @@ def main(argv: Optional[list[str]] = None) -> None: 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 +1318,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/manager.py b/celune/extensions/manager.py index 82b452d..b6c5c09 100644 --- a/celune/extensions/manager.py +++ b/celune/extensions/manager.py @@ -16,6 +16,7 @@ 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 ( @@ -131,14 +132,13 @@ def autostart_all(self) -> None: """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( - "CeluneExtensionManager.autostart_all() is deprecated, " - "please use @celune.subscribe('ready') in your extensions instead", + string("extensions.autostart_all_deprecated"), DeprecationWarning, stacklevel=2, ) @@ -152,7 +152,13 @@ def runner(e=ext, n=name): 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", ) @@ -184,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", ) @@ -209,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}") @@ -233,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 @@ -242,8 +252,11 @@ 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 @@ -270,14 +283,21 @@ 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", ) @@ -316,8 +336,7 @@ def _register_legacy_autostart_handler(self, extension: CeluneExtension) -> None return warnings.warn( - "CeluneExtension.autostart() is deprecated, " - "please use @celune.subscribe('ready') instead", + string("extensions.autostart_deprecated"), DeprecationWarning, stacklevel=2, ) diff --git a/celune/i18n.py b/celune/i18n.py index 4eecb69..543eddf 100644 --- a/celune/i18n.py +++ b/celune/i18n.py @@ -11,9 +11,460 @@ DEFAULT_LOCALE = "en" STRINGS: dict[str, dict[str, str]] = { - "en": {}, + "en": { + "ui.wait_placeholder": "Please wait", + "ui.no_voice_set": "No Voice Set", + "webui.wait_placeholder": "Please wait", + "webui.tutorial_placeholder": "Currently in tutorial mode", + "webui.input_placeholder": "Enter text to speak here", + "webui.default_voice_button": "Balanced", + "webui.send_button": "Send", + "ui.invalid_theme_defaulting_dark": "Invalid theme, defaulting to dark", + "ui.sleeping_log": "{app_name} is currently sleeping. Type anything to wake up.", + "ui.sleeping_status": "Sleeping", + "ui.app_could_not_start": "{app_name} could not start", + "ui.idle_status": "Idle", + "ui.tutorial_prompt": "New to {app_name}? Type /tutorial to begin the tutorial.", + "ui.error_signal_unavailable": "Could not play the error signal.", + "ui.no_voices_loaded": "No voices are loaded.", + "ui.core_engine_not_loaded": "Core engine is not loaded.", + "headless.warn_prefix": "[WARN] ", + "headless.error_prefix": "[ERROR] ", + "headless.no_attached_instance": "{class_name} has no attached {app_name} instance: this will do nothing", + "commands.no_tutorial_assets": "No tutorial assets found.", + "commands.tutorial_playback_failed": "Tutorial playback failed: {error}", + "commands.tutorial_failed": "Tutorial failed: {error}", + "commands.help_header": "--- {app_name} help topics ---", + "commands.help_available": "Available commands:", + "commands.help_arguments": ( + "Arguments marked in <> are required, those marked in [] are optional." + ), + "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_invoke": "/invoke [args] - Invoke a {app_name} extension by its name.", + "commands.help_extensions": "/extensions - List currently available {app_name} extensions.", + "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_speed": "/speed - Change speaking speed.", + "commands.help_reverb": "/reverb - Change reverb strength.", + "commands.help_backend": "/backend - Hot-reload a TTS backend.", + "commands.help_cevoice": "/cevoice - Hot-reload a CEVOICE pack.", + "commands.help_xvectoronly": "/xvectoronly - Toggle Qwen3 identity-only cloning.", + "commands.help_play": "/play - Play a sound effect by path.", + "commands.help_attach": ( + "/attach [file...] - Attach one or more images or videos to the " + "next persona reply." + ), + "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_tutorial": "/tutorial - Run {app_name}'s tutorial.", + "commands.help_stop": "/stop - Terminate ongoing speech.", + "commands.help_exit": "/exit - Exit {app_name}.", + "commands.help_help": "/help - Display this help message.", + "commands.usage_consumebuffer": "Usage: /consumebuffer ", + "commands.consuming_live_input": "Now consuming from live input", + "commands.not_consuming_live_input": "No longer consuming from live input", + "commands.invalid_true_false_argument": "Invalid argument for '{command}', must be true/false.", + "commands.usage_invoke": "Usage: /invoke [args]", + "commands.extension_system_not_initialized": "Extension system not initialized.", + "commands.extension_not_found": "Extension not found: {name}", + "commands.extension_error": "[EXT ERROR] {error}", + "commands.no_extensions_loaded": "No extensions loaded.", + "commands.extensions_list": "Extensions: {names}", + "commands.voice_prompts_unavailable": "Voice prompts are unavailable with the currently loaded model.", + "commands.usage_voiceprompt": "Usage: /voiceprompt ", + "commands.voice_prompt_cleared": "Voice prompt cleared.", + "commands.voice_prompt_set": "Voice prompt set to '{prompt}'.", + "commands.rubber_band_unavailable": "{app_name} cannot currently use Rubber Band.", + "commands.usage_speed": "Usage: /speed ", + "commands.value_out_of_range_speed": "Value out of range. Expected 80-120%.", + "commands.invalid_argument": "Invalid argument: {value}", + "commands.speed_set": "Speaking speed set to {value}%.", + "commands.usage_reverb": "Usage: /reverb ", + "commands.value_out_of_range_reverb": "Value out of range. Expected 0-100%.", + "commands.reverb_set": "Reverb strength set to {value}%.", + "commands.usage_backend": "Usage: /backend ", + "commands.backend_already_loaded": "This backend is already loaded.", + "commands.backend_switched": "Switched to backend: {backend_name}", + "commands.backend_not_switched": "Backend was not switched.", + "commands.backend_switch_failed": "Failed to switch backend: {error}", + "commands.usage_cevoice": "Usage: /cevoice ", + "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.xvectoronly_qwen3_only": "This setting is only available on the Qwen3 backend.", + "commands.usage_xvectoronly": "Usage: /xvectoronly ", + "commands.qwen3_identity_only_cloning": "Qwen3 identity-only cloning {state}.", + "commands.state_enabled": "enabled", + "commands.state_disabled": "disabled", + "commands.usage_play": "Usage: /play [volume]", + "commands.invalid_volume": "Invalid volume for '{command}', must be numeric.", + "commands.playing_youtube_audio": "Playing YouTube audio at {volume}% volume", + "commands.playing_audio": "Playing {path} at {volume}% volume", + "commands.cannot_play_audio": "Cannot play this audio: {error}", + "commands.cannot_play_file": "Cannot play this file: {error}", + "commands.usage_attach": "Usage: /attach [file...]", + "commands.attachments_cleared": "Attachments cleared.", + "commands.attachments_speech_only_mode": ( + "Cannot add attachments while {app_name} is running in speech-only mode." + ), + "commands.attachment_not_found": "Attachment not found: {path}", + "commands.unsupported_attachment_type": "Unsupported attachment type: {path}", + "commands.attachments_added": "Attached {names}. {count} attachments will be sent in the next pass.", + "commands.usage_say": "Usage: /say ", + "commands.say_redundant": "This command is redundant in the current operation mode.", + "commands.say_submit_normally": "Submit inputs normally instead.", + "commands.unmatched_ipa": "Found {count} unmatched IPA characters, output may be inaccurate.", + "commands.custom_seed_removed": "Custom seed removed.", + "commands.seed_range": "Seed must be between 0 and {max_value}.", + "commands.seed_set": "Seed set to {value}.", + "commands.tutorial_activated": "Tutorial activated. Listen to what's said to learn how to use {app_name}.", + "commands.nothing_to_stop": "Nothing to stop.", + "commands.unknown_command": "Unknown command: {command}. Run /help for a list of commands.", + "cli.dependency_missing": "You do not have '{package_name}' installed.", + "cli.dependency_required": "{app_name} requires this library to function.", + "cli.setup_automatically": "Set up {app_name} automatically by running:", + "cli.setup_cmd_setup_py": " python setup.py", + "cli.setup_with_uv": "or alternatively with uv:", + "cli.setup_cmd_uv_sync": " uv sync", + "cli.install_manually": "or install the package manually:", + "cli.setup_cmd_pip_install": " pip install {package_name}", + "cli.full_traceback": "for full traceback:", + "cli.traceback_cmd_set_dev": "set CELUNE_DEV=1", + "cli.traceback_cmd_python": " python {script_name}", + "cli.traceback_cmd_dev_python": " CELUNE_DEV=1 python {script_name}", + "cli.doctor_usage": "Usage: {program} doctor [--fix]", + "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_checking": "Checking your environment for compatibility with {app_name}...", + "cli.doctor_hint": "hint: {hint}", + "cli.doctor_summary": "Summary: {passes} passed, {warnings_count} warning{warning_suffix}, {failures} failed", + "cli.doctor_ready": "Your system is ready to run {app_name}.", + "cli.doctor_performance_impacted": "{app_name}'s performance may be impacted.", + "cli.doctor_will_not_work": "{app_name} will not work.", + "cli.doctor_rerun_fix": "Rerun with --fix to attempt to fix some of these problems.", + "cli.doctor_fix_limits": ( + "Please note that this will not fix a Python version incompatibility or " + "lack of a CUDA runtime." + ), + "cli.doctor_attempting_fix": "Attempting to fix fixable problems...", + "cli.invalid_argument": "Invalid argument.", + "cli.too_many_arguments": "Too many arguments.", + "cli.start_usage": "Usage: {program} {command}", + "cli.start_description": "Start {app_name}.", + "cli.help_usage": "Usage: {program} help", + "cli.help_description": "Display this help message.", + "cli.help_main_usage": "Usage: {program} [command]", + "cli.help_available_commands": "Available commands:", + "cli.help_start": "start/run [-v]\t\t\tStart {app_name}.", + "cli.help_config": "config [view/edit]\t\tView or edit {app_name}'s configuration.", + "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_version": "version\t\t\t\tDisplay running {app_name} version.", + "cli.help_parameter_note": "Some commands may be used with a parameter (e.g. {program} --argument),", + "cli.help_subcommand_note": "or with a subcommand (e.g. {program} argument).", + "cli.help_default_start": "Providing no arguments implicitly defaults to starting {app_name}.", + "cli.version_usage": "Usage: {program} version", + "cli.version_description": "Display running {app_name} version.", + "cli.modified_version_note": "Note: This is a modified version of {app_name}.", + "cli.unknown_command_or_argument": "Unknown command or argument.", + "cli.run_help_hint": "Run `{program} help` to list available commands.", + }, } +STRINGS["en"].update( + { + "status.idle": "Idle", + "status.speaking": "Speaking", + "status.thinking": "Thinking", + "status.waking_up": "Waking up", + "status.reloading": "Reloading", + "status.reloading_backend": "Reloading backend", + "status.reloading_character": "Reloading character", + "status.restoring_backend": "Restoring backend", + "status.sleeping": "Sleeping", + "status.initializing": "Initializing", + "status.generating": "Generating", + "status.normalizing": "Normalizing", + "status.waiting_for_model": "Waiting for model", + "status.downloading_audio": "Downloading audio", + "status.warming_up": "Warming up", + "status.api_starting": "Starting up", + "status.could_not_start": "{app_name} could not start", + "status.could_not_continue": "{app_name} could not continue", + "status.could_not_wake": "{app_name} could not wake", + "status.could_not_reload": "{app_name} could not reload", + "webui.voice_ready": "Voice ready: {voice}.", + "webui.voice_changed": "Voice changed to {voice}.", + "webui.sleeping_log": "{app_name} is currently sleeping. Type anything to wake up.", + "webui.must_be_running_for_commands": "{app_name} must be running to run commands.", + "webui.command_parsing_error": "Command parsing error: {error}", + "webui.not_available": "I'm not currently available.", + "webui.wrong_mode": "I am not currently able to do this.", + "webui.not_returned_from_sleep": "{app_name} has not yet returned from sleep mode.", + "webui.busy_try_again": "I'm currently busy. Try again later.", + "webui.error": "[WEBUI ERROR] {error}", + "webui.upload_audio_first": "Upload or record audio before starting voice conversion.", + "webui.conversion_only_in_vc_mode": "Audio conversion is only available in voice conversion mode.", + "webui.cannot_convert_right_now": "I can't convert that right now.", + "webui.cannot_change_voice_right_now": "I can't change my voice right now.", + "webui.features_may_differ": "Usage may differ. Some {app_name} features may not be available.", + "api.unauthorized": "Who are you? Send me an authentication token.", + "api.rate_limit": "Please wait until you make me speak again.", + "api.runner_started": "{app_name} API has started on http://{host}:{port}", + "api.runner_exit_code": "API runner has exited. Exit code {code}", + "api.could_not_start": "Could not start the API: {error}", + "api.runner_timeout": "API runner has not responded after {seconds:.1f}s, and has timed out.", + "api.speech_job_unknown": "I don't know that speech job.", + "api.invalid_voice": "I don't know how to speak in that voice.", + "api.sound_too_large": "That sound is too large for me to play.", + "api.invalid_input": "I don't understand your input.", + "api.cannot_play_now": "I can't play that right now.", + "api.source_audio_too_large": "That source audio is too large for me to convert.", + "api.could_not_convert": "I couldn't convert that.", + "api.cannot_convert": "I can't convert that.", + "celune.residual_temp_item": "{app_name} found a residual temporary item.", + "celune.residual_temp_items": "{app_name} found {count} residual temporary items.", + "celune.deleting": "Deleting...", + "celune.persona_init_failed": "Persona could not be initialized.", + "celune.switching_backend": "{app_name} is switching to {backend}, please wait...", + "celune.switched_backend": "Switched backend to {backend}.", + "celune.reload_error": "[RELOAD ERROR] {error}", + "celune.backend_restore_failed": "Could not load this backend. The previous backend was restored.", + "celune.reloading_character": "{app_name} is reloading the character, please stand by...", + "celune.switched_character": "Switched to character: {character}", + "celune.character_restore_failed": "Could not switch to this character. The previous character was restored.", + "celune.persona_not_initialized": "Persona not initialized.", + "celune.speech_only_mode": "Continuing in speech-only mode.", + "celune.wake_error": "[WAKE ERROR] {error}", + "celune.unknown_voice": "Unknown voice: {voice}", + "celune.waiting_for_models": "Waiting for models to load...", + "celune.voice_switch_timeout": "Timed out while switching voice.", + "celune.reload_already_in_progress": "A backend or character reload is already in progress.", + "celune.unknown_backend": "Unknown backend: {backend} (available: {available})", + "celune.backend_switch_timeout": "Timed out while switching backends.", + "celune.voice_pack_not_found": "Voice pack not found: {bundle}", + "celune.character_switch_timeout": "Timed out while switching characters.", + "celune.ready_wait_timeout": "Timed out while waiting to become ready.", + "celune.ready_wait_reason": "A possible reason for this may be a model download or high GPU activity.", + "celune.ready_wait_not_fatal": "This is not a fatal error, the utterance may be retried.", + "celune.model_unloaded_while_waiting": "Model was unloaded while waiting to become ready.", + "celune.playback_idle_timeout": "Timed out while waiting for playback pipeline to become idle.", + "celune.reloading": "{app_name} is reloading, please stand by...", + "celune.rewarming_up": "Rewarming up...", + "celune.voice_loaded": "Voice {voice} loaded.", + "celune.no_voices_loaded": "No voices were loaded.", + "celune.current_character_default": "Current character: {character} (default)", + "celune.current_character": "Current character: {character}", + "celune.current_vram_preset": "Current VRAM preset: {preset}", + "celune.no_vc_backend": "No voice conversion backend is available.", + "celune.no_voices_loaded_short": "No voices loaded", + "celune.no_valid_vc_backend": "No valid voice conversion backend", + "celune.ready_for_vc": "Ready to accept voice conversions.", + "celune.all_voices_available": "All voices are available.", + "celune.default_model_load_failed": "{app_name} could not load the default model.", + "celune.default_model_failed_short": "Default model failed to load", + "celune.initializing_persona": "Initializing Persona...", + "celune.persona_initialized": "Persona initialized.", + "celune.warmup_failed": "[WARMUP] Warmup failed.", + "celune.personas_unavailable": "Personas are unavailable. {app_name} is operating in speech-only mode.", + "celune.no_api_token": "No API token set. {app_name} API will bind only to the local network.", + "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.port_unavailable": "Port {port} is unavailable.", + "celune.api_unavailable": "{app_name} API will not be available.", + "celune.required_package_missing": "A required package ({package}) isn't installed.", + "celune.internal_error": "An internal error occurred: {error}", + "celune.package_import_failed": "Package import failed: {error}", + "celune.normalizer_loaded": "Normalizer loaded.", + "celune.normalizer_error": "[NORMALIZER ERROR] {error}", + "celune.normalizer_failed": "Normalizer failed to load.", + "celune.normalization_unavailable": "Normalization will not be available.", + "celune.loading_normalizer": "Loading normalizer {model_id} on {device}...", + "celune.warmup_start": "[WARMUP] Warming up...", + "celune.warmup_error": "[WARMUP ERROR] {error}", + "celune.warmup_failed_app": "{app_name} could not warm up", + "celune.tokens_to_normalize": "Tokens to normalize: {count}", + "celune.input_too_long_to_normalize": "Input is too long to normalize.", + "celune.normalizer_returned_no_tokens": "Normalizer returned no tokens.", + "celune.normalizer_bad_output": "Normalizer did not produce normal output.", + "celune.normalized_text": "Normalized text: {text}", + "celune.normalization_took": "Normalization took {seconds} seconds.", + "celune.normalization_error": "[NORMALIZATION ERROR] {error}", + "celune.text_input_unavailable_vc": "Text input is unavailable in voice conversion mode.", + "celune.speech_input_disabled_tutorial": "Speech input is disabled during the tutorial.", + "celune.cannot_think_sleeping": "Cannot think while {app_name} is sleeping.", + "celune.busy_thinking": "Tried to think while {app_name} was busy.", + "celune.app_sleeping": "{app_name} is currently sleeping", + "celune.app_busy": "{app_name} is currently busy", + "celune.say_instead": "Will say the input instead.", + "celune.audio_conversion_unavailable": "Audio conversion is unavailable outside voice conversion mode.", + "celune.not_possible": "Not possible", + "pipeline.ttfp_seconds": "TTFP: {seconds} seconds", + "pipeline.forcefully_stopping_speech": "Forcefully stopping speech.", + "pipeline.busy_action": "Tried to {action} while {app_name} was busy.", + "pipeline.yt_dlp_missing": "yt-dlp is not installed, cannot play YouTube audio.", + "pipeline.yt_dlp_required": "yt-dlp is required for YouTube playback", + "pipeline.youtube_download_start": "[SFX] Downloading audio from {url}...", + "pipeline.download_failed": "Could not download audio.", + "pipeline.download_youtube_failed_short": "Could not download YouTube audio", + "pipeline.download_timeout": "Timed out downloading audio.", + "pipeline.downloader_no_file": "Downloader returned no file.", + "pipeline.persona_not_connected": "Persona system is not connected.", + "pipeline.persona_request_failed": "Persona system request failed: {error}", + "pipeline.persona_empty_response": "Persona system returned an empty response.", + "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.cannot_speak_sleeping": "Cannot speak while {app_name} is sleeping.", + "pipeline.speak_waiting_reload": "Speak request is waiting for model reload to finish.", + "pipeline.received_unsupported_language": "Received unsupported input in the following language: {language}", + "pipeline.may_not_say_properly": "{app_name} may not say the input properly.", + "pipeline.april_fools": "We are about to do a funny!", + "pipeline.sample_rate_length": "Sample rate: {sample_rate} Hz, length: {seconds} seconds", + "pipeline.playing_label": "Playing {label}", + "pipeline.cannot_find_sound": "{app_name} cannot find {path}.", + "pipeline.sfx_format_unsupported": "{app_name} does not support SFX in this format.", + "pipeline.supported_formats": "Supported formats: {formats}", + "pipeline.exiting": "Exiting...", + "pipeline.nothing_to_say": "Nothing to say", + "pipeline.rubber_band_unavailable": "Rubber Band is unavailable, speed controls disabled.", + "pipeline.token_limit_reached": ( + "Generation reached the token limit before completion. Output may be " + "truncated or sound incorrect." + ), + "pipeline.generation_summary": "[GEN] {speech_seconds} seconds, took {generation_seconds} seconds", + "pipeline.generation_speed": "Speed: x{speed}", + "pipeline.ttfc_ms": "TTFC: {milliseconds} ms", + "pipeline.generation_done": "[GEN] done", + "pipeline.silent_regenerating": "Previous utterance was silent, regenerating...", + "pipeline.may_be_silent": "This utterance may be unexpectedly silent.", + "pipeline.outputs_path_creating": "Outputs path not found, creating...", + "pipeline.outputs_create_failed": "Cannot create outputs directory, not saving FLAC output: {error}", + "pipeline.flac_save_failed": "Could not save FLAC output: {error}", + "pipeline.gen_error": "[GEN ERROR] {error}", + "pipeline.could_not_generate": "{app_name} could not generate the input", + "pipeline.not_ready_app": "{app_name} is not currently ready", + "pipeline.audio_stream_init_failed": "{app_name} could not initialize the audio stream.", + "pipeline.no_audio_device": "No suitable audio device is available.", + "pipeline.no_audio_devices_short": "No suitable audio devices", + "pipeline.just_type": "Just type. {choice}", + "pipeline.ready_to_speak": "Ready to speak.", + "pipeline.vram_low": ( + "{app_name} is running out of VRAM. Check the bottom right of " + "{app_name}'s window to learn more." + ), + "pipeline.close_memory_apps": "Please close any memory-resident applications to improve performance.", + "pipeline.playback_error": "Playback error", + "extensions.autostart_once": "[Core] Cannot autostart Celune extensions more than one time.", + "extensions.autostart_all_deprecated": ( + "CeluneExtensionManager.autostart_all() is deprecated, please use " + "@celune.subscribe('ready') in your extensions instead" + ), + "extensions.autostart_failed": "[Core] Could not autostart {name}: {error}", + "extensions.invoke_failed": "[Core] Failed to invoke '{name}': {error}", + "extensions.folder_not_found": "[Core] Extension folder not found: {path}", + "extensions.unavailable": "Extensions will not be available.", + "extensions.path_not_directory": "[Core] Extension path is not a directory: {path}", + "extensions.spec_load_failed": "[Core] Could not load spec for: {name}", + "extensions.import_failed": "[Core] Failed to import '{name}': {error}", + "extensions.register_failed": "[Core] Failed to register '{name}' from '{file_name}': {error}", + "extensions.not_extension_skipping": "[Core] {file_name} is not a Celune extension, skipping", + "extensions.autostart_deprecated": ( + "CeluneExtension.autostart() is deprecated, please use " + "@celune.subscribe('ready') instead" + ), + "cli.fix_failed": "Failed to fix fixable problems: {error}", + "cli.config_not_created": "{app_name} configuration has not been created yet.", + "cli.run_once_to_create_config": "Run {app_name} at least once to create it.", + "cli.current_config": "Current {app_name} configuration:", + "cli.config_could_not_be_read": "{app_name} configuration could not be read.", + "cli.config_usage": "Usage: {program} config [view/edit]", + "cli.config_description": "View or edit {app_name}'s configuration.", + "cli.no_argument_given": "No argument given.", + "cli.running_from_ide": "{app_name} is running from {ide}.", + "cli.ide_terminals_differ": "Some IDE terminals may behave differently from a normal terminal.", + "cli.config_created": "{app_name} configuration has been created.", + "cli.config_updated_defaults": "{app_name} configuration has been updated with new defaults.", + "cli.launcher_apply_artifact": "{app_name} will close so the launcher can apply the latest artifact.", + "cli.update_info_incomplete": "update information is incomplete", + "cli.update_found": "New update found.", + "cli.update_prompt": "Do you want to update?", + "cli.update_version_summary": ( + "You are running {app_name} {local_version} ({local_revision}), latest " + "version is {latest_label} ({latest_revision})." + ), + "cli.update_choice_yes": "Yes, update now", + "cli.update_choice_no": "No, continue as is", + "cli.updating": "Updating {app_name}...", + "cli.update_failed": "{app_name} could not update: {detail}", + "cli.continuing_current_version": "Continuing with the current version.", + "cli.update_success_restart": "{app_name} updated successfully. Restart {app_name} to apply changes.", + "cli.no_ansi": "This terminal does not support ANSI.", + "cli.request_refresh_binaries": "Requesting the launcher to refresh the packaged binaries...", + "cli.apply_update_noninteractive": "Attempting to apply update non-interactively...", + "cli.not_via_launcher": "{app_name} is not being launched via the {app_name} launcher.", + "cli.suppress_message_run_with": "To suppress this message, run {app_name} with:", + "cli.or_set_env_var": "or set the following environment variable:", + "cli.already_running": "{app_name} is already running.", + "cli.could_not_initialize": "{app_name} could not initialize.", + "cli.running_headless": "{app_name} is running in headless mode.", + "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.cannot_start_normal_mode": "{app_name} cannot start in normal mode.", + "cli.hint": "Hint:", + "cli.try_another_terminal": "Try using another terminal application.", + "cli.internal_error_running": "An internal error occurred while {app_name} was running.", + "cli.no_error_description": "no error description", + "cli.full_traceback_title": "For full traceback:", + "cli.additional_debugging": "additional debugging:", + "cli.set_dev_true": "Set 'dev: true' in config.yaml", + "cli.celine_day_1": "I sense the presence of... her.", + "cli.celine_day_2": "I would rather not.", + "cli.try_again_tomorrow": "Try again tomorrow.", + "cli.apply_update_usage": "Usage: celune __apply_update [args...]", + "cli.invalid_launcher_pid": "Invalid launcher PID.", + "cli.apply_launcher_update_failed": "{app_name} could not apply the launcher update: {error}", + } +) + + +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 get_system_locale() -> str: """Get the current system locale, falling back to English if unavailable. @@ -71,11 +522,14 @@ 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): + 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/pipeline.py b/celune/pipeline.py index 2c7dfd5..f2a6c3c 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -98,6 +98,7 @@ JSONSerializable, PERSONA_MEMORY_EMBEDDING_MODEL, ) +from .i18n import string from .typing.pipeline import SpeechStreamQueue @@ -414,7 +415,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: @@ -463,7 +464,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: @@ -488,8 +489,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 @@ -755,8 +759,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() @@ -766,8 +770,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": @@ -802,20 +806,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 @@ -1357,7 +1361,7 @@ def think(engine: Celune, request: str) -> bool: 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) @@ -1365,7 +1369,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: @@ -1373,7 +1381,7 @@ 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 history = getattr(engine, "persona_history", None) @@ -1461,8 +1469,8 @@ def convert_audio_input( """ backend = getattr(engine, "vc_backend", None) if backend is None: - engine.log("Voice conversion backend is not configured.", "warning") - engine.error_callback("Voice conversion backend is not configured") + engine.log(string("pipeline.vc_backend_unconfigured"), "warning") + engine.error_callback(string("pipeline.vc_backend_unconfigured")) engine.progress_callback(0, 1) return None @@ -1475,9 +1483,11 @@ def convert_audio_input( target_references = (loader.materialize(current_voice, "wav"),) except Exception as e: engine.log( - "Could not load reference audio for voice conversion:" - f"{format_error(e, getattr(engine, 'dev', False))}" - "warning" + string( + "pipeline.vc_reference_load_failed", + error=format_error(e, getattr(engine, "dev", False)), + ), + "warning", ) target_references = () @@ -1516,25 +1526,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") + 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 @@ -1560,10 +1573,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", @@ -1572,7 +1587,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"): @@ -1581,8 +1596,8 @@ def queue_speech( try: if not engine.loaded: - engine.log("Core engine is not loaded.", "warning") - engine.error_callback(f"{APP_NAME} is not currently ready") + 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 @@ -1598,7 +1613,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: @@ -1634,7 +1649,11 @@ def queue_sfx_audio( 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" + string( + "pipeline.sample_rate_length", + sample_rate=sample_rate, + seconds=format_number(audio_len, 2), + ) ) audio = resample_audio(audio, sample_rate) @@ -1650,7 +1669,11 @@ def queue_sfx_audio( _queue_playback_chunk(engine, source_id, chunk, BASE_SR) _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() @@ -1684,14 +1707,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") @@ -1712,7 +1747,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: @@ -1960,7 +1995,7 @@ def generation_worker(engine: Celune) -> None: engine.model_ready.wait() if not engine.loaded: - engine.log("Core engine is not loaded.", "warning") + 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")) @@ -1993,7 +2028,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")) @@ -2014,7 +2049,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: @@ -2125,7 +2160,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 @@ -2200,8 +2235,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", ) @@ -2223,18 +2257,31 @@ 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) @@ -2253,7 +2300,7 @@ def generation_worker(engine: Celune) -> None: engine.cur_state = "speaking" engine.queue_avail_callback() - engine.log("[GEN] done") + engine.log(string("pipeline.generation_done")) saved_path = None analysis_audio = None @@ -2281,13 +2328,12 @@ def generation_worker(engine: Celune) -> None: # 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) @@ -2301,13 +2347,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", ) @@ -2335,8 +2385,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 @@ -2357,7 +2409,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) @@ -2365,7 +2423,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 @@ -2408,9 +2468,12 @@ def _ensure_playback_stream(engine: Celune, sample_rate: int) -> bool: return True 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 @@ -2447,7 +2510,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...") @@ -2467,18 +2530,18 @@ def _finalize_playback_idle( and getattr(engine, "loaded", False) and not getattr(engine, "_ready_announced", False) ): - engine.log("Ready to speak.") + 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", ) @@ -2665,7 +2728,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 diff --git a/celune/ui/app.py b/celune/ui/app.py index 08603ad..10bc710 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -39,6 +39,7 @@ 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, @@ -582,8 +583,8 @@ 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) with Horizontal(id="bottom"): yield Label("", id="status") yield Label("", id="resources") @@ -648,7 +649,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 @@ -964,10 +965,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) @@ -978,7 +979,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.""" @@ -993,7 +994,7 @@ def load_tts(self) -> None: if not self.celune_styles: 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) @@ -1004,7 +1005,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] ) @@ -1012,9 +1013,7 @@ 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.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( @@ -1024,16 +1023,16 @@ def load_tts(self) -> None: 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.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 @@ -1644,12 +1643,12 @@ def on_button_pressed(self, event: Button.Pressed) -> None: 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") + self.safe_log(string("ui.core_engine_not_loaded"), "warning") self.change_voice_lock_state(locked=True) return diff --git a/celune/ui/commands.py b/celune/ui/commands.py index c5e468a..d6ead0f 100644 --- a/celune/ui/commands.py +++ b/celune/ui/commands.py @@ -17,6 +17,7 @@ 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 @@ -70,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 = ( @@ -100,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) @@ -113,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) @@ -152,62 +159,41 @@ 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") - ui.safe_log( - f"/invoke [args] - Invoke a {APP_NAME} extension by its name." - ) - ui.safe_log(f"/extensions - List currently available {APP_NAME} extensions.") + 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( - 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", - ) + ui.safe_log(string("commands.help_voiceprompt", app_name=APP_NAME)) + ui.safe_log(string("commands.help_voiceprompt_caution"), "warning") - ui.safe_log("/speed - Change speaking speed.") - ui.safe_log("/reverb - Change reverb strength.") - ui.safe_log("/backend - Hot-reload a TTS backend.") - ui.safe_log("/cevoice - Hot-reload a CEVOICE pack.") + 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 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"]: @@ -215,19 +201,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] @@ -236,34 +224,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() @@ -271,18 +259,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: @@ -292,17 +283,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: @@ -312,38 +303,40 @@ 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(f"Reverb strength set to {args[0]}%.") + ui.safe_log(string("commands.reverb_set", value=args[0])) return if command == "backend": if not args: - ui.safe_log("Usage: /backend ", "warning") + ui.safe_log(string("commands.usage_backend"), "warning") return backend_name = args[0] if backend_name == ui.celune.backend.name: - ui.safe_log("This backend is already loaded.", "warning") + 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(f"Switched to backend: {backend_name}") - else: ui.safe_log( - "Backend was not switched.", - "warning", + 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( - f"Failed to switch backend: {format_error(exc, ui.celune.dev)}", + string( + "commands.backend_switch_failed", + error=format_error(exc, ui.celune.dev), + ), "error", ) @@ -351,26 +344,29 @@ def backend_worker() -> None: return if command == "cevoice": if not args: - ui.safe_log("Usage: /cevoice ", "warning") + ui.safe_log(string("commands.usage_cevoice"), "warning") return bundle = args[0] if resolve_bundle_path(bundle) == active_bundle_path(): - ui.safe_log("This character is already loaded.", "warning") + 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(f"Character changed: {bundle}") + ui.safe_log(string("commands.character_changed", bundle=bundle)) else: ui.safe_log( - f"Could not switch character to {bundle}.", + string("commands.character_not_switched", bundle=bundle), "warning", ) except Exception as exc: ui.safe_log( - f"Cannot switch to this character: {format_error(exc, ui.celune.dev)}", + string( + "commands.character_switch_failed", + error=format_error(exc, ui.celune.dev), + ), "error", ) @@ -379,30 +375,32 @@ def cevoice_worker() -> None: 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: @@ -412,7 +410,7 @@ def cevoice_worker() -> 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 @@ -423,40 +421,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 @@ -475,7 +486,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() @@ -484,7 +497,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() @@ -502,22 +518,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: @@ -526,12 +546,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", ) @@ -544,38 +564,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 @@ -583,6 +601,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/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_pipeline.py b/tests/test_pipeline.py index e10cbc4..62ffa05 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -352,7 +352,7 @@ def test_handle_audio_input_reports_missing_vc_backend_cleanly(self) -> None: engine.log.assert_called_once() self.assertEqual( engine.errors, - ["Voice conversion backend is not configured"], + ["Voice conversion backend is not configured."], ) self.assertEqual(engine.audio_queue.empty(), True) From 8395f2ebd6d5171b7f13f3bf08777c9a913da130 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Wed, 24 Jun 2026 19:57:51 +0200 Subject: [PATCH 14/26] fix: missing osc strings --- celune/constants.py | 9 +++++---- celune/entrypoint.py | 19 +++++++++++-------- celune/i18n.py | 6 ++++++ celune/ui/app.py | 4 +++- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/celune/constants.py b/celune/constants.py index 74b7404..1e85c53 100644 --- a/celune/constants.py +++ b/celune/constants.py @@ -7,6 +7,7 @@ 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 @@ -166,9 +167,9 @@ class UtteranceLoudnessTier(IntEnum): CRASH_LINES = itertools.cycle( [ - f"{APP_NAME} has crashed.", - f"Please check {APP_NAME}'s", - "log widget for", - "more information.", + 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/entrypoint.py b/celune/entrypoint.py index c05831d..6a20072 100644 --- a/celune/entrypoint.py +++ b/celune/entrypoint.py @@ -972,7 +972,7 @@ def start(verbose: bool = False) -> None: runtime = _load_runtime() try: if runtime.supports_ansi(): - sys.stdout.write(f"\x1b]2;{APP_NAME} is starting up...\x07") + 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( @@ -1179,7 +1179,7 @@ def start(verbose: bool = False) -> None: install() raise - print(exc or string("cli.no_error_description")) + print(str(exc) or string("cli.no_error_description")) print(string("cli.full_traceback_title")) if os.name == "nt": print(string("cli.traceback_cmd_set_dev")) @@ -1257,12 +1257,15 @@ def main(argv: Optional[list[str]] = None) -> None: if not args: start() elif args[0] in {"start", "run"}: - if args[1] not in {"--verbose", "-v"}: - print(string("cli.invalid_argument")) - print() - 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) + if len(args) > 1: + if args[1] not in {"--verbose", "-v"}: + print(string("cli.invalid_argument")) + print() + 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) elif args[0] == "config": diff --git a/celune/i18n.py b/celune/i18n.py index 543eddf..eb60e78 100644 --- a/celune/i18n.py +++ b/celune/i18n.py @@ -440,6 +440,12 @@ "cli.apply_update_usage": "Usage: celune __apply_update [args...]", "cli.invalid_launcher_pid": "Invalid launcher PID.", "cli.apply_launcher_update_failed": "{app_name} could not apply the launcher update: {error}", + "osc.starting": "{app_name} is starting up...", + "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...", } ) diff --git a/celune/ui/app.py b/celune/ui/app.py index 10bc710..e242afb 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -1662,7 +1662,9 @@ def on_button_pressed(self, event: Button.Pressed) -> None: def on_unmount(self) -> None: """Unload Celune.""" - self._write_terminal_escape(f"\x1b]2;{APP_NAME} is exiting...\x07") + self._write_terminal_escape( + f"\x1b]2;{string('osc.exiting', app_name=APP_NAME)}\x07" + ) if self.celune is not None: self.celune.close() From 9286c0a70df7f142921f641a2c438cc182036277 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Fri, 26 Jun 2026 17:50:17 +0200 Subject: [PATCH 15/26] feat: SeedVC support, i18n from json --- AGENTS.md | 10 + celune/api.py | 365 ++++++++--- celune/backends/__init__.py | 83 +-- celune/backends/tts/__init__.py | 83 +++ celune/backends/{ => tts}/base.py | 12 +- celune/backends/{ => tts}/dotstts.py | 4 +- celune/backends/{ => tts}/mini.py | 8 +- celune/backends/{ => tts}/qwen3.py | 4 +- celune/backends/{ => tts}/voxcpm2.py | 6 +- .../{vc_backends => backends/vc}/__init__.py | 9 +- celune/{vc_backends => backends/vc}/base.py | 2 +- .../vc}/passthrough.py | 2 +- celune/backends/vc/seedvc.py | 248 ++++++++ celune/celune.py | 462 +++++++++++--- celune/dataclasses/celune.py | 14 +- celune/dataclasses/pipeline.py | 5 + celune/dsp.py | 25 + celune/i18n.py | 484 ++------------- celune/lang/en.json | 437 +++++++++++++ celune/modeling.py | 2 +- celune/pipeline.py | 82 ++- celune/typing/celune.py | 15 +- celune/ui/app.py | 574 +++++++++++++++++- celune/ui/commands.py | 150 ++++- celune/ui/resources.py | 3 + celune/ui/terminal.py | 43 +- celune/ui/theme.py | 4 +- celune/vram.py | 11 +- default_config.yaml | 8 + pyproject.toml | 61 +- scripts/run_ci.py | 44 +- tests/support.py | 12 +- tests/test_api_audio.py | 8 +- tests/test_api_webui.py | 115 +++- tests/test_backends_and_extensions.py | 317 +++++++++- tests/test_backends_mini.py | 2 +- tests/test_celune_core.py | 279 ++++++++- tests/test_modeling.py | 2 +- tests/test_pipeline.py | 75 +++ tests/test_runtime_and_ui_commands.py | 441 +++++++++++++- uv.lock | 562 ++++++++++++++++- 41 files changed, 4186 insertions(+), 887 deletions(-) create mode 100644 celune/backends/tts/__init__.py rename celune/backends/{ => tts}/base.py (98%) rename celune/backends/{ => tts}/dotstts.py (99%) rename celune/backends/{ => tts}/mini.py (98%) rename celune/backends/{ => tts}/qwen3.py (99%) rename celune/backends/{ => tts}/voxcpm2.py (99%) rename celune/{vc_backends => backends/vc}/__init__.py (90%) rename celune/{vc_backends => backends/vc}/base.py (92%) rename celune/{vc_backends => backends/vc}/passthrough.py (92%) create mode 100644 celune/backends/vc/seedvc.py create mode 100644 celune/lang/en.json diff --git a/AGENTS.md b/AGENTS.md index 317a512..147b568 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,16 @@ If this process updates typing or dataclass related docstrings, remove the place This process may leave some formatting inaccuracies, run `uv run ruff format .` again after completing docstrings. +## 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. diff --git a/celune/api.py b/celune/api.py index 036d831..60ae155 100644 --- a/celune/api.py +++ b/celune/api.py @@ -39,7 +39,7 @@ from .utils import format_error from .dsp import resample_audio from .cevoice import default_loader -from .pipeline import SpeechStreamQueue +from .pipeline import SpeechStreamQueue, prepare_playback_audio from .ui import resources as ui_resources from .paths import main_window_log_path, project_root from .constants import BASE_SR, APP_NAME, JSONSerializable @@ -99,6 +99,7 @@ class _WebUiUnset: html, body, gradio-app { + --color-accent: var(--celune-primary, #cebaff) !important; background: var(--celune-ui-bg, var(--celune-background, #1d1826)) !important; } @@ -112,11 +113,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; } @@ -155,13 +167,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); } @@ -586,12 +598,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 = ( - string("webui.tutorial_placeholder") - if celune.is_in_tutorial - else string("webui.wait_placeholder") - if celune and webui_input_locked - else string("webui.input_placeholder") + 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 @@ -787,12 +797,10 @@ 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 = ( - string("webui.tutorial_placeholder") - if celune.is_in_tutorial - else string("webui.wait_placeholder") - if celune and webui_input_locked - else string("webui.input_placeholder") + webui_input_placeholder = _webui_input_placeholder( + celune, + webui_input_locked, + has_voice, ) original_input_state(locked) @@ -1044,6 +1052,80 @@ def _webui_resources_html() -> str: return f'' +def _webui_shortcuts_html() -> str: + """Render browser-side keyboard shortcuts for WebUI-only controls.""" + return textwrap.dedent( + """ + + """ + ).strip() + + def _voice_button_update() -> WebUiUpdate: """Return the current browser voice-button state.""" celune = bound_celune @@ -1065,6 +1147,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: @@ -1096,14 +1201,10 @@ def _input_update( 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 = ( - string("webui.wait_placeholder") - if celune and (celune.locked or not has_voice) - else string("webui.input_placeholder") - ) + placeholder = _webui_input_placeholder(celune, celune.locked, has_voice) if has_value: return gr.update( value=value, @@ -1130,6 +1231,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 = bool(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, @@ -1310,6 +1429,8 @@ def _webui_speak( def _webui_convert_audio( source_audio: WebUiInputAudioValue, + pitch_shift: float = 0.0, + conversion_mode: str = "talk", ) -> tuple[ WebUiInputAudioValue, WebUiAudioValue, @@ -1360,7 +1481,13 @@ def _webui_convert_audio( sample_rate, audio = source_audio api_log("CONVERT(WEBUI)", "uploaded audio") try: - output = celune.convert_audio(audio, sample_rate, label="browser audio input") + 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)), @@ -1394,7 +1521,8 @@ def _webui_convert_audio( send_update, ) - converted_audio = (output.sample_rate, np.asarray(output.audio, dtype=np.float32)) + 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() ) @@ -1455,58 +1583,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=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, + with gr.Tabs(): + with gr.Tab(string("webui.tts_tab_label")): + gr.HTML( + textwrap.dedent( + f""" +
+
+
{APP_NAME}
+
+
+ """ + ) ) - send_button = gr.Button( - value=string("webui.send_button"), - 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""" -

- {string("webui.features_may_differ", app_name=APP_NAME)} -

- """) - ) + 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", @@ -1522,11 +1697,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], @@ -1560,6 +1745,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 @@ -1877,11 +2076,15 @@ 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 @@ -1917,7 +2120,12 @@ async def convert_audio( try: output = await run_in_threadpool( - celune.convert_audio, audio, sample_rate, label=filename + celune.convert_audio, + audio, + sample_rate, + label=filename, + pitch_shift=pitch_shift, + f0_condition=f0_condition, ) except Exception: return JSONResponse( @@ -1938,15 +2146,16 @@ async def convert_audio( ) def chunks() -> Iterator[bytes]: + prepared_audio = prepare_playback_audio(output.audio, output.sample_rate) yield _flac_bytes( - np.asarray(output.audio, dtype=np.float32), - sample_rate=output.sample_rate, + prepared_audio, + sample_rate=BASE_SR, ) return StreamingResponse( chunks(), media_type="audio/flac", - headers=stream_headers(output.sample_rate), + headers=stream_headers(BASE_SR), ) diff --git a/celune/backends/__init__.py b/celune/backends/__init__.py index a04c4c6..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 typing import Callable, Union, Optional -from importlib import import_module -from importlib.metadata import version, PackageNotFoundError - -from .base import CeluneBackend -from ..typing.backends import BackendModel - -__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..58ead6e --- /dev/null +++ b/celune/backends/tts/__init__.py @@ -0,0 +1,83 @@ +# 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 ...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( + 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" + ) diff --git a/celune/backends/base.py b/celune/backends/tts/base.py similarity index 98% rename from celune/backends/base.py rename to celune/backends/tts/base.py index 0f87d0b..1889efd 100644 --- a/celune/backends/base.py +++ b/celune/backends/tts/base.py @@ -30,12 +30,12 @@ import soundfile as sf from huggingface_hub import snapshot_download -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 +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", diff --git a/celune/backends/dotstts.py b/celune/backends/tts/dotstts.py similarity index 99% rename from celune/backends/dotstts.py rename to celune/backends/tts/dotstts.py index d2bd6b4..06746e0 100644 --- a/celune/backends/dotstts.py +++ b/celune/backends/tts/dotstts.py @@ -13,8 +13,8 @@ import numpy.typing as npt from dots_tts.runtime import DotsTtsRuntime -from ..utils import custom_assert, discard -from ..cevoice import default_loader, CEVoiceLoader +from ...utils import custom_assert, discard +from ...cevoice import default_loader, CEVoiceLoader from .base import CeluneBackend, cached_hf_snapshot_path, local_hf_offline_mode diff --git a/celune/backends/mini.py b/celune/backends/tts/mini.py similarity index 98% rename from celune/backends/mini.py rename to celune/backends/tts/mini.py index 751cacd..9b199b8 100644 --- a/celune/backends/mini.py +++ b/celune/backends/tts/mini.py @@ -14,10 +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 ..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 diff --git a/celune/backends/qwen3.py b/celune/backends/tts/qwen3.py similarity index 99% rename from celune/backends/qwen3.py rename to celune/backends/tts/qwen3.py index 1fa3edf..05ef462 100644 --- a/celune/backends/qwen3.py +++ b/celune/backends/tts/qwen3.py @@ -10,8 +10,8 @@ import numpy.typing as npt from faster_qwen3_tts import FasterQwen3TTS, __version__ as qwen3_ver -from ..utils import custom_assert -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 diff --git a/celune/backends/voxcpm2.py b/celune/backends/tts/voxcpm2.py similarity index 99% rename from celune/backends/voxcpm2.py rename to celune/backends/tts/voxcpm2.py index 0f7d7d8..ca90c3f 100644 --- a/celune/backends/voxcpm2.py +++ b/celune/backends/tts/voxcpm2.py @@ -12,9 +12,9 @@ from voxcpm import VoxCPM from . import get_version -from ..constants import BASE_SR -from ..utils import custom_assert -from ..cevoice import default_loader, CEVoiceLoader +from ...constants import BASE_SR +from ...utils import custom_assert +from ...cevoice import default_loader, CEVoiceLoader from .base import CeluneBackend, cached_hf_snapshot_path, local_hf_offline_mode diff --git a/celune/vc_backends/__init__.py b/celune/backends/vc/__init__.py similarity index 90% rename from celune/vc_backends/__init__.py rename to celune/backends/vc/__init__.py index 422b4d3..a85ebd4 100644 --- a/celune/vc_backends/__init__.py +++ b/celune/backends/vc/__init__.py @@ -9,7 +9,14 @@ __all__ = ["CeluneVCBackend", "resolve_vc_backend"] VC_BACKENDS = { - "passthrough": ("celune.vc_backends.passthrough", "CelunePassthroughVCBackend"), + "passthrough": ( + "celune.backends.vc.passthrough", + "CelunePassthroughVCBackend", + ), + "seed-vc": ( + "celune.backends.vc.seedvc", + "CeluneSeedVCBackend", + ), } diff --git a/celune/vc_backends/base.py b/celune/backends/vc/base.py similarity index 92% rename from celune/vc_backends/base.py rename to celune/backends/vc/base.py index fd8d442..ab0efb5 100644 --- a/celune/vc_backends/base.py +++ b/celune/backends/vc/base.py @@ -4,7 +4,7 @@ from typing import Callable from abc import ABC, abstractmethod -from ..dataclasses.pipeline import AudioOutput, VoiceConversionRequest +from ...dataclasses.pipeline import AudioOutput, VoiceConversionRequest __all__ = ["CeluneVCBackend"] diff --git a/celune/vc_backends/passthrough.py b/celune/backends/vc/passthrough.py similarity index 92% rename from celune/vc_backends/passthrough.py rename to celune/backends/vc/passthrough.py index 7a7198a..29f47d8 100644 --- a/celune/vc_backends/passthrough.py +++ b/celune/backends/vc/passthrough.py @@ -4,7 +4,7 @@ import numpy as np from .base import CeluneVCBackend -from ..dataclasses.pipeline import AudioOutput, VoiceConversionRequest +from ...dataclasses.pipeline import AudioOutput, VoiceConversionRequest __all__ = ["CelunePassthroughVCBackend"] diff --git a/celune/backends/vc/seedvc.py b/celune/backends/vc/seedvc.py new file mode 100644 index 0000000..088529c --- /dev/null +++ b/celune/backends/vc/seedvc.py @@ -0,0 +1,248 @@ +# 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 ...paths import app_data_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_checkpoints_dir(create: bool = False) -> Path: + """Return the persistent Celune cache directory used for Seed-VC assets.""" + path = app_data_dir(create=create) / "checkpoints" + if create: + path.mkdir(parents=True, exist_ok=True) + return path + + @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_checkpoints_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( + "Seed-VC backend requires the 'seed_vc' package to be installed" + ) 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("Loading Seed-VC 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: + Result of this function. + """ + 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( + "Seed-VC requires at least one target reference WAV for conversion" + ) + + 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=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 3df990b..8cc3bbf 100644 --- a/celune/celune.py +++ b/celune/celune.py @@ -45,20 +45,21 @@ 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, app_data_dir from .runtime import log_runtime_banner, validate_runtime -from .backends import BACKENDS, CeluneBackend, resolve_backend -from .vc_backends import CeluneVCBackend, resolve_vc_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, @@ -66,6 +67,8 @@ NormalizerTokenizer, ProgressCallback, ReleasableObject, + TTSBackendSpec, + VCBackendSpec, VoiceLockStateCallback, ) from .typing.backends import BackendModel @@ -145,6 +148,28 @@ 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 @@ -163,6 +188,65 @@ def _resolve_input_mode(config: Config, requested_mode: Optional[str] = None) -> 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) @@ -200,7 +284,12 @@ class _ReloadSnapshot: 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, ...] @@ -214,8 +303,11 @@ class _ReloadSnapshot: def __init__( self, config: Config, - tts_backend: Optional[Union[str, CeluneBackend, type[CeluneBackend]]] = None, - vc_backend: Optional[Union[str, CeluneVCBackend, type[CeluneVCBackend]]] = 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, @@ -262,6 +354,23 @@ def __init__( 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) @@ -305,11 +414,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, ) @@ -339,12 +452,20 @@ def __init__( try: if vc_backend is not None: + resolved_vc_backend = cast(VCBackendSpec, vc_backend) if not isinstance(vc_backend, CeluneVCBackend): - self._vc_backend_spec = vc_backend + self._vc_backend_spec = cast( + Union[str, type[CeluneVCBackend]], + resolved_vc_backend, + ) self.vc_backend = resolve_vc_backend( - 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 @@ -603,9 +724,19 @@ 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: + Result of this function. + """ + 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" + 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.""" @@ -699,9 +830,33 @@ def _recreate_vc_backend(self) -> bool: 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]], @@ -724,13 +879,18 @@ def _backend_reload_kwargs( return backend_kwargs def _capture_reload_snapshot(self) -> _ReloadSnapshot: - """Capture the current TTS runtime state for rollback.""" + """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, @@ -746,6 +906,7 @@ 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]]: @@ -755,11 +916,15 @@ def _restorable_backend_spec(self) -> Union[str, type[CeluneBackend]]: return type(self.backend) def _restore_reload_snapshot(self, snapshot: _ReloadSnapshot) -> None: - """Restore TTS runtime state after a failed hot reload.""" + """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 @@ -793,6 +958,23 @@ def _rebuild_reload_snapshot_runtime(self, snapshot: _ReloadSnapshot) -> None: 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, @@ -857,24 +1039,34 @@ def _load_backend_voice_runtime( def _hot_reload_backend( self, - backend_spec: Union[str, CeluneBackend, type[CeluneBackend]], + backend_spec: CoreBackendSpec, preferred_voice: Optional[str] = None, ) -> bool: - """Synchronously switch to a new TTS backend with rollback on failure.""" + """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) + if isinstance(backend_spec, (CeluneBackend, CeluneVCBackend)) else str(backend_spec) ) try: snapshot = self._capture_reload_snapshot() preset = resolve_vram_preset(self.config) - candidate_kwargs = self._backend_reload_kwargs(backend_spec) + 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", @@ -887,79 +1079,126 @@ def _hot_reload_backend( self.progress_callback(None, None) self.cur_state = "reloading" - if self._is_voice_conversion_mode(): - raise BackendError("TTS backends are not available in VC mode") + previous_backend = self.backend + previous_vc_backend = self.vc_backend + previous_voice = snapshot.current_voice - candidate_backend = resolve_backend( - 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() + 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") + ( + 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() - previous_backend = self.backend - previous_voice = snapshot.current_voice - 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 - 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") - 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() - self.backend = candidate_backend - 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 + 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._backend_spec = ( - backend_spec - if not isinstance(backend_spec, CeluneBackend) - else type(candidate_backend) - ) - self._backend_kwargs = dict(candidate_kwargs) self._release_reload_snapshot(snapshot) - if self.use_normalization: + 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 - 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, - ), - ) + 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" @@ -985,12 +1224,24 @@ def _hot_reload_backend( ): _release_loaded_object(candidate_model) if ( - snapshot is not None - and snapshot.backend.model is None - and snapshot.loaded + candidate_vc_backend is not None + and snapshot is not None + and candidate_vc_backend is not snapshot.vc_backend ): - self._rebuild_reload_snapshot_runtime(snapshot) - self._restore_reload_snapshot(snapshot) + 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 @@ -1485,9 +1736,9 @@ def set_voice_and_wait(self, name: str, timeout: float = 30.0) -> bool: def set_backend( self, - backend_spec: Union[str, CeluneBackend, type[CeluneBackend]], + backend_spec: CoreBackendSpec, ) -> bool: - """Request a hot reload into another TTS backend. + """Request a hot reload into another TTS or VC backend. Args: backend_spec: The backend name, type, or instance to activate. @@ -1500,12 +1751,17 @@ def set_backend( return False if isinstance(backend_spec, str): normalized_backend = backend_spec.strip().lower() - if normalized_backend not in BACKENDS: + if ( + normalized_backend not in BACKENDS + and normalized_backend not in VC_BACKENDS + ): self.log( string( "celune.unknown_backend", backend=backend_spec, - available=", ".join(BACKENDS.keys()), + available=", ".join( + tuple(BACKENDS.keys()) + tuple(VC_BACKENDS.keys()) + ), ), "warning", ) @@ -1527,7 +1783,7 @@ def set_backend( def set_backend_and_wait( self, - backend_spec: Union[str, CeluneBackend, type[CeluneBackend]], + backend_spec: CoreBackendSpec, timeout: Optional[float] = None, ) -> bool: """Request a hot backend reload and wait for completion. @@ -1549,10 +1805,13 @@ def set_backend_and_wait( target_name = ( backend_spec.name - if isinstance(backend_spec, CeluneBackend) + if isinstance(backend_spec, (CeluneBackend, CeluneVCBackend)) else getattr(backend_spec, "name", str(backend_spec)) ) - return self.loaded and self.tts_backend == str(target_name).lower() + 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. @@ -1613,7 +1872,7 @@ def set_cevoice_and_wait( @contextlib.contextmanager def with_backend( self, - backend_spec: Union[str, CeluneBackend, type[CeluneBackend]], + backend_spec: CoreBackendSpec, timeout: float = 30.0, ): """Temporarily switch Celune to another backend within a context block. @@ -1625,7 +1884,7 @@ def with_backend( Raises: BackendError: Celune could not switch to or restore the requested backend. """ - restore_backend = self._restorable_backend_spec() + 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") @@ -2429,7 +2688,7 @@ def think( """ if self.input_mode != "text_to_speech": self.log(string("celune.text_input_unavailable_vc"), "warning") - self.error_callback(string("celune.text_input_unavailable_vc")) + self.error_callback(string("celune.not_possible")) return False if self.is_in_tutorial: @@ -2494,7 +2753,7 @@ def say( """ if self.input_mode != "text_to_speech": self.log(string("celune.text_input_unavailable_vc"), "warning") - self.error_callback(string("celune.text_input_unavailable_vc")) + self.error_callback(string("celune.not_possible")) self.progress_callback(0, 1) return False @@ -2521,6 +2780,9 @@ def submit_audio( 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, ) -> bool: """Accept audio input for future non-TTS engine modes. @@ -2528,6 +2790,9 @@ def submit_audio( 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. Returns: bool: ``True`` when the current mode accepted the audio input. @@ -2538,6 +2803,9 @@ def submit_audio( 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, ), ) @@ -2546,6 +2814,8 @@ def convert_audio( 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. @@ -2553,6 +2823,8 @@ def convert_audio( 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. @@ -2572,6 +2844,8 @@ def convert_audio( audio=np.asarray(audio, dtype=np.float32), sample_rate=sample_rate, label=label, + pitch_shift=pitch_shift, + f0_condition=f0_condition, ), ) diff --git a/celune/dataclasses/celune.py b/celune/dataclasses/celune.py index 4641e84..d58999d 100644 --- a/celune/dataclasses/celune.py +++ b/celune/dataclasses/celune.py @@ -14,9 +14,9 @@ from ..config import Config from ..chroma import AudioRGBGlow from ..cevoice import CEVoicePersona -from ..backends import CeluneBackend +from ..backends.tts import CeluneBackend from ..persona.impl import PersonaClient -from ..vc_backends import CeluneVCBackend +from ..backends.vc import CeluneVCBackend from ..typing.backends import BackendModel from ..dsp import StreamingPedalboardReverb from ..extensions.manager import CeluneExtensionManager @@ -29,6 +29,8 @@ MessageCallback, ProgressCallback, QueueAvailableCallback, + TTSBackendRecipe, + VCBackendRecipe, VoiceChangedCallback, VoiceLockStateCallback, ) @@ -54,13 +56,15 @@ 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[Union[str, type[CeluneVCBackend]]] = None + 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" @@ -187,6 +191,8 @@ class CeluneRuntimeState: "_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"), diff --git a/celune/dataclasses/pipeline.py b/celune/dataclasses/pipeline.py index 9e0a725..fbebe9d 100644 --- a/celune/dataclasses/pipeline.py +++ b/celune/dataclasses/pipeline.py @@ -35,6 +35,9 @@ class AudioInputRequest: 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 @dataclass(frozen=True) @@ -47,6 +50,8 @@ class VoiceConversionRequest: 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) diff --git a/celune/dsp.py b/celune/dsp.py index f8ba6d2..0842a52 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: Value for `audio`. + sample_rate: Value for `sample_rate`. + n_steps: Value for `n_steps`. + + Returns: + Result of this function. + """ + 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/i18n.py b/celune/i18n.py index eb60e78..84a5737 100644 --- a/celune/i18n.py +++ b/celune/i18n.py @@ -1,453 +1,19 @@ # 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": { - "ui.wait_placeholder": "Please wait", - "ui.no_voice_set": "No Voice Set", - "webui.wait_placeholder": "Please wait", - "webui.tutorial_placeholder": "Currently in tutorial mode", - "webui.input_placeholder": "Enter text to speak here", - "webui.default_voice_button": "Balanced", - "webui.send_button": "Send", - "ui.invalid_theme_defaulting_dark": "Invalid theme, defaulting to dark", - "ui.sleeping_log": "{app_name} is currently sleeping. Type anything to wake up.", - "ui.sleeping_status": "Sleeping", - "ui.app_could_not_start": "{app_name} could not start", - "ui.idle_status": "Idle", - "ui.tutorial_prompt": "New to {app_name}? Type /tutorial to begin the tutorial.", - "ui.error_signal_unavailable": "Could not play the error signal.", - "ui.no_voices_loaded": "No voices are loaded.", - "ui.core_engine_not_loaded": "Core engine is not loaded.", - "headless.warn_prefix": "[WARN] ", - "headless.error_prefix": "[ERROR] ", - "headless.no_attached_instance": "{class_name} has no attached {app_name} instance: this will do nothing", - "commands.no_tutorial_assets": "No tutorial assets found.", - "commands.tutorial_playback_failed": "Tutorial playback failed: {error}", - "commands.tutorial_failed": "Tutorial failed: {error}", - "commands.help_header": "--- {app_name} help topics ---", - "commands.help_available": "Available commands:", - "commands.help_arguments": ( - "Arguments marked in <> are required, those marked in [] are optional." - ), - "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_invoke": "/invoke [args] - Invoke a {app_name} extension by its name.", - "commands.help_extensions": "/extensions - List currently available {app_name} extensions.", - "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_speed": "/speed - Change speaking speed.", - "commands.help_reverb": "/reverb - Change reverb strength.", - "commands.help_backend": "/backend - Hot-reload a TTS backend.", - "commands.help_cevoice": "/cevoice - Hot-reload a CEVOICE pack.", - "commands.help_xvectoronly": "/xvectoronly - Toggle Qwen3 identity-only cloning.", - "commands.help_play": "/play - Play a sound effect by path.", - "commands.help_attach": ( - "/attach [file...] - Attach one or more images or videos to the " - "next persona reply." - ), - "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_tutorial": "/tutorial - Run {app_name}'s tutorial.", - "commands.help_stop": "/stop - Terminate ongoing speech.", - "commands.help_exit": "/exit - Exit {app_name}.", - "commands.help_help": "/help - Display this help message.", - "commands.usage_consumebuffer": "Usage: /consumebuffer ", - "commands.consuming_live_input": "Now consuming from live input", - "commands.not_consuming_live_input": "No longer consuming from live input", - "commands.invalid_true_false_argument": "Invalid argument for '{command}', must be true/false.", - "commands.usage_invoke": "Usage: /invoke [args]", - "commands.extension_system_not_initialized": "Extension system not initialized.", - "commands.extension_not_found": "Extension not found: {name}", - "commands.extension_error": "[EXT ERROR] {error}", - "commands.no_extensions_loaded": "No extensions loaded.", - "commands.extensions_list": "Extensions: {names}", - "commands.voice_prompts_unavailable": "Voice prompts are unavailable with the currently loaded model.", - "commands.usage_voiceprompt": "Usage: /voiceprompt ", - "commands.voice_prompt_cleared": "Voice prompt cleared.", - "commands.voice_prompt_set": "Voice prompt set to '{prompt}'.", - "commands.rubber_band_unavailable": "{app_name} cannot currently use Rubber Band.", - "commands.usage_speed": "Usage: /speed ", - "commands.value_out_of_range_speed": "Value out of range. Expected 80-120%.", - "commands.invalid_argument": "Invalid argument: {value}", - "commands.speed_set": "Speaking speed set to {value}%.", - "commands.usage_reverb": "Usage: /reverb ", - "commands.value_out_of_range_reverb": "Value out of range. Expected 0-100%.", - "commands.reverb_set": "Reverb strength set to {value}%.", - "commands.usage_backend": "Usage: /backend ", - "commands.backend_already_loaded": "This backend is already loaded.", - "commands.backend_switched": "Switched to backend: {backend_name}", - "commands.backend_not_switched": "Backend was not switched.", - "commands.backend_switch_failed": "Failed to switch backend: {error}", - "commands.usage_cevoice": "Usage: /cevoice ", - "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.xvectoronly_qwen3_only": "This setting is only available on the Qwen3 backend.", - "commands.usage_xvectoronly": "Usage: /xvectoronly ", - "commands.qwen3_identity_only_cloning": "Qwen3 identity-only cloning {state}.", - "commands.state_enabled": "enabled", - "commands.state_disabled": "disabled", - "commands.usage_play": "Usage: /play [volume]", - "commands.invalid_volume": "Invalid volume for '{command}', must be numeric.", - "commands.playing_youtube_audio": "Playing YouTube audio at {volume}% volume", - "commands.playing_audio": "Playing {path} at {volume}% volume", - "commands.cannot_play_audio": "Cannot play this audio: {error}", - "commands.cannot_play_file": "Cannot play this file: {error}", - "commands.usage_attach": "Usage: /attach [file...]", - "commands.attachments_cleared": "Attachments cleared.", - "commands.attachments_speech_only_mode": ( - "Cannot add attachments while {app_name} is running in speech-only mode." - ), - "commands.attachment_not_found": "Attachment not found: {path}", - "commands.unsupported_attachment_type": "Unsupported attachment type: {path}", - "commands.attachments_added": "Attached {names}. {count} attachments will be sent in the next pass.", - "commands.usage_say": "Usage: /say ", - "commands.say_redundant": "This command is redundant in the current operation mode.", - "commands.say_submit_normally": "Submit inputs normally instead.", - "commands.unmatched_ipa": "Found {count} unmatched IPA characters, output may be inaccurate.", - "commands.custom_seed_removed": "Custom seed removed.", - "commands.seed_range": "Seed must be between 0 and {max_value}.", - "commands.seed_set": "Seed set to {value}.", - "commands.tutorial_activated": "Tutorial activated. Listen to what's said to learn how to use {app_name}.", - "commands.nothing_to_stop": "Nothing to stop.", - "commands.unknown_command": "Unknown command: {command}. Run /help for a list of commands.", - "cli.dependency_missing": "You do not have '{package_name}' installed.", - "cli.dependency_required": "{app_name} requires this library to function.", - "cli.setup_automatically": "Set up {app_name} automatically by running:", - "cli.setup_cmd_setup_py": " python setup.py", - "cli.setup_with_uv": "or alternatively with uv:", - "cli.setup_cmd_uv_sync": " uv sync", - "cli.install_manually": "or install the package manually:", - "cli.setup_cmd_pip_install": " pip install {package_name}", - "cli.full_traceback": "for full traceback:", - "cli.traceback_cmd_set_dev": "set CELUNE_DEV=1", - "cli.traceback_cmd_python": " python {script_name}", - "cli.traceback_cmd_dev_python": " CELUNE_DEV=1 python {script_name}", - "cli.doctor_usage": "Usage: {program} doctor [--fix]", - "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_checking": "Checking your environment for compatibility with {app_name}...", - "cli.doctor_hint": "hint: {hint}", - "cli.doctor_summary": "Summary: {passes} passed, {warnings_count} warning{warning_suffix}, {failures} failed", - "cli.doctor_ready": "Your system is ready to run {app_name}.", - "cli.doctor_performance_impacted": "{app_name}'s performance may be impacted.", - "cli.doctor_will_not_work": "{app_name} will not work.", - "cli.doctor_rerun_fix": "Rerun with --fix to attempt to fix some of these problems.", - "cli.doctor_fix_limits": ( - "Please note that this will not fix a Python version incompatibility or " - "lack of a CUDA runtime." - ), - "cli.doctor_attempting_fix": "Attempting to fix fixable problems...", - "cli.invalid_argument": "Invalid argument.", - "cli.too_many_arguments": "Too many arguments.", - "cli.start_usage": "Usage: {program} {command}", - "cli.start_description": "Start {app_name}.", - "cli.help_usage": "Usage: {program} help", - "cli.help_description": "Display this help message.", - "cli.help_main_usage": "Usage: {program} [command]", - "cli.help_available_commands": "Available commands:", - "cli.help_start": "start/run [-v]\t\t\tStart {app_name}.", - "cli.help_config": "config [view/edit]\t\tView or edit {app_name}'s configuration.", - "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_version": "version\t\t\t\tDisplay running {app_name} version.", - "cli.help_parameter_note": "Some commands may be used with a parameter (e.g. {program} --argument),", - "cli.help_subcommand_note": "or with a subcommand (e.g. {program} argument).", - "cli.help_default_start": "Providing no arguments implicitly defaults to starting {app_name}.", - "cli.version_usage": "Usage: {program} version", - "cli.version_description": "Display running {app_name} version.", - "cli.modified_version_note": "Note: This is a modified version of {app_name}.", - "cli.unknown_command_or_argument": "Unknown command or argument.", - "cli.run_help_hint": "Run `{program} help` to list available commands.", - }, -} - -STRINGS["en"].update( - { - "status.idle": "Idle", - "status.speaking": "Speaking", - "status.thinking": "Thinking", - "status.waking_up": "Waking up", - "status.reloading": "Reloading", - "status.reloading_backend": "Reloading backend", - "status.reloading_character": "Reloading character", - "status.restoring_backend": "Restoring backend", - "status.sleeping": "Sleeping", - "status.initializing": "Initializing", - "status.generating": "Generating", - "status.normalizing": "Normalizing", - "status.waiting_for_model": "Waiting for model", - "status.downloading_audio": "Downloading audio", - "status.warming_up": "Warming up", - "status.api_starting": "Starting up", - "status.could_not_start": "{app_name} could not start", - "status.could_not_continue": "{app_name} could not continue", - "status.could_not_wake": "{app_name} could not wake", - "status.could_not_reload": "{app_name} could not reload", - "webui.voice_ready": "Voice ready: {voice}.", - "webui.voice_changed": "Voice changed to {voice}.", - "webui.sleeping_log": "{app_name} is currently sleeping. Type anything to wake up.", - "webui.must_be_running_for_commands": "{app_name} must be running to run commands.", - "webui.command_parsing_error": "Command parsing error: {error}", - "webui.not_available": "I'm not currently available.", - "webui.wrong_mode": "I am not currently able to do this.", - "webui.not_returned_from_sleep": "{app_name} has not yet returned from sleep mode.", - "webui.busy_try_again": "I'm currently busy. Try again later.", - "webui.error": "[WEBUI ERROR] {error}", - "webui.upload_audio_first": "Upload or record audio before starting voice conversion.", - "webui.conversion_only_in_vc_mode": "Audio conversion is only available in voice conversion mode.", - "webui.cannot_convert_right_now": "I can't convert that right now.", - "webui.cannot_change_voice_right_now": "I can't change my voice right now.", - "webui.features_may_differ": "Usage may differ. Some {app_name} features may not be available.", - "api.unauthorized": "Who are you? Send me an authentication token.", - "api.rate_limit": "Please wait until you make me speak again.", - "api.runner_started": "{app_name} API has started on http://{host}:{port}", - "api.runner_exit_code": "API runner has exited. Exit code {code}", - "api.could_not_start": "Could not start the API: {error}", - "api.runner_timeout": "API runner has not responded after {seconds:.1f}s, and has timed out.", - "api.speech_job_unknown": "I don't know that speech job.", - "api.invalid_voice": "I don't know how to speak in that voice.", - "api.sound_too_large": "That sound is too large for me to play.", - "api.invalid_input": "I don't understand your input.", - "api.cannot_play_now": "I can't play that right now.", - "api.source_audio_too_large": "That source audio is too large for me to convert.", - "api.could_not_convert": "I couldn't convert that.", - "api.cannot_convert": "I can't convert that.", - "celune.residual_temp_item": "{app_name} found a residual temporary item.", - "celune.residual_temp_items": "{app_name} found {count} residual temporary items.", - "celune.deleting": "Deleting...", - "celune.persona_init_failed": "Persona could not be initialized.", - "celune.switching_backend": "{app_name} is switching to {backend}, please wait...", - "celune.switched_backend": "Switched backend to {backend}.", - "celune.reload_error": "[RELOAD ERROR] {error}", - "celune.backend_restore_failed": "Could not load this backend. The previous backend was restored.", - "celune.reloading_character": "{app_name} is reloading the character, please stand by...", - "celune.switched_character": "Switched to character: {character}", - "celune.character_restore_failed": "Could not switch to this character. The previous character was restored.", - "celune.persona_not_initialized": "Persona not initialized.", - "celune.speech_only_mode": "Continuing in speech-only mode.", - "celune.wake_error": "[WAKE ERROR] {error}", - "celune.unknown_voice": "Unknown voice: {voice}", - "celune.waiting_for_models": "Waiting for models to load...", - "celune.voice_switch_timeout": "Timed out while switching voice.", - "celune.reload_already_in_progress": "A backend or character reload is already in progress.", - "celune.unknown_backend": "Unknown backend: {backend} (available: {available})", - "celune.backend_switch_timeout": "Timed out while switching backends.", - "celune.voice_pack_not_found": "Voice pack not found: {bundle}", - "celune.character_switch_timeout": "Timed out while switching characters.", - "celune.ready_wait_timeout": "Timed out while waiting to become ready.", - "celune.ready_wait_reason": "A possible reason for this may be a model download or high GPU activity.", - "celune.ready_wait_not_fatal": "This is not a fatal error, the utterance may be retried.", - "celune.model_unloaded_while_waiting": "Model was unloaded while waiting to become ready.", - "celune.playback_idle_timeout": "Timed out while waiting for playback pipeline to become idle.", - "celune.reloading": "{app_name} is reloading, please stand by...", - "celune.rewarming_up": "Rewarming up...", - "celune.voice_loaded": "Voice {voice} loaded.", - "celune.no_voices_loaded": "No voices were loaded.", - "celune.current_character_default": "Current character: {character} (default)", - "celune.current_character": "Current character: {character}", - "celune.current_vram_preset": "Current VRAM preset: {preset}", - "celune.no_vc_backend": "No voice conversion backend is available.", - "celune.no_voices_loaded_short": "No voices loaded", - "celune.no_valid_vc_backend": "No valid voice conversion backend", - "celune.ready_for_vc": "Ready to accept voice conversions.", - "celune.all_voices_available": "All voices are available.", - "celune.default_model_load_failed": "{app_name} could not load the default model.", - "celune.default_model_failed_short": "Default model failed to load", - "celune.initializing_persona": "Initializing Persona...", - "celune.persona_initialized": "Persona initialized.", - "celune.warmup_failed": "[WARMUP] Warmup failed.", - "celune.personas_unavailable": "Personas are unavailable. {app_name} is operating in speech-only mode.", - "celune.no_api_token": "No API token set. {app_name} API will bind only to the local network.", - "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.port_unavailable": "Port {port} is unavailable.", - "celune.api_unavailable": "{app_name} API will not be available.", - "celune.required_package_missing": "A required package ({package}) isn't installed.", - "celune.internal_error": "An internal error occurred: {error}", - "celune.package_import_failed": "Package import failed: {error}", - "celune.normalizer_loaded": "Normalizer loaded.", - "celune.normalizer_error": "[NORMALIZER ERROR] {error}", - "celune.normalizer_failed": "Normalizer failed to load.", - "celune.normalization_unavailable": "Normalization will not be available.", - "celune.loading_normalizer": "Loading normalizer {model_id} on {device}...", - "celune.warmup_start": "[WARMUP] Warming up...", - "celune.warmup_error": "[WARMUP ERROR] {error}", - "celune.warmup_failed_app": "{app_name} could not warm up", - "celune.tokens_to_normalize": "Tokens to normalize: {count}", - "celune.input_too_long_to_normalize": "Input is too long to normalize.", - "celune.normalizer_returned_no_tokens": "Normalizer returned no tokens.", - "celune.normalizer_bad_output": "Normalizer did not produce normal output.", - "celune.normalized_text": "Normalized text: {text}", - "celune.normalization_took": "Normalization took {seconds} seconds.", - "celune.normalization_error": "[NORMALIZATION ERROR] {error}", - "celune.text_input_unavailable_vc": "Text input is unavailable in voice conversion mode.", - "celune.speech_input_disabled_tutorial": "Speech input is disabled during the tutorial.", - "celune.cannot_think_sleeping": "Cannot think while {app_name} is sleeping.", - "celune.busy_thinking": "Tried to think while {app_name} was busy.", - "celune.app_sleeping": "{app_name} is currently sleeping", - "celune.app_busy": "{app_name} is currently busy", - "celune.say_instead": "Will say the input instead.", - "celune.audio_conversion_unavailable": "Audio conversion is unavailable outside voice conversion mode.", - "celune.not_possible": "Not possible", - "pipeline.ttfp_seconds": "TTFP: {seconds} seconds", - "pipeline.forcefully_stopping_speech": "Forcefully stopping speech.", - "pipeline.busy_action": "Tried to {action} while {app_name} was busy.", - "pipeline.yt_dlp_missing": "yt-dlp is not installed, cannot play YouTube audio.", - "pipeline.yt_dlp_required": "yt-dlp is required for YouTube playback", - "pipeline.youtube_download_start": "[SFX] Downloading audio from {url}...", - "pipeline.download_failed": "Could not download audio.", - "pipeline.download_youtube_failed_short": "Could not download YouTube audio", - "pipeline.download_timeout": "Timed out downloading audio.", - "pipeline.downloader_no_file": "Downloader returned no file.", - "pipeline.persona_not_connected": "Persona system is not connected.", - "pipeline.persona_request_failed": "Persona system request failed: {error}", - "pipeline.persona_empty_response": "Persona system returned an empty response.", - "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.cannot_speak_sleeping": "Cannot speak while {app_name} is sleeping.", - "pipeline.speak_waiting_reload": "Speak request is waiting for model reload to finish.", - "pipeline.received_unsupported_language": "Received unsupported input in the following language: {language}", - "pipeline.may_not_say_properly": "{app_name} may not say the input properly.", - "pipeline.april_fools": "We are about to do a funny!", - "pipeline.sample_rate_length": "Sample rate: {sample_rate} Hz, length: {seconds} seconds", - "pipeline.playing_label": "Playing {label}", - "pipeline.cannot_find_sound": "{app_name} cannot find {path}.", - "pipeline.sfx_format_unsupported": "{app_name} does not support SFX in this format.", - "pipeline.supported_formats": "Supported formats: {formats}", - "pipeline.exiting": "Exiting...", - "pipeline.nothing_to_say": "Nothing to say", - "pipeline.rubber_band_unavailable": "Rubber Band is unavailable, speed controls disabled.", - "pipeline.token_limit_reached": ( - "Generation reached the token limit before completion. Output may be " - "truncated or sound incorrect." - ), - "pipeline.generation_summary": "[GEN] {speech_seconds} seconds, took {generation_seconds} seconds", - "pipeline.generation_speed": "Speed: x{speed}", - "pipeline.ttfc_ms": "TTFC: {milliseconds} ms", - "pipeline.generation_done": "[GEN] done", - "pipeline.silent_regenerating": "Previous utterance was silent, regenerating...", - "pipeline.may_be_silent": "This utterance may be unexpectedly silent.", - "pipeline.outputs_path_creating": "Outputs path not found, creating...", - "pipeline.outputs_create_failed": "Cannot create outputs directory, not saving FLAC output: {error}", - "pipeline.flac_save_failed": "Could not save FLAC output: {error}", - "pipeline.gen_error": "[GEN ERROR] {error}", - "pipeline.could_not_generate": "{app_name} could not generate the input", - "pipeline.not_ready_app": "{app_name} is not currently ready", - "pipeline.audio_stream_init_failed": "{app_name} could not initialize the audio stream.", - "pipeline.no_audio_device": "No suitable audio device is available.", - "pipeline.no_audio_devices_short": "No suitable audio devices", - "pipeline.just_type": "Just type. {choice}", - "pipeline.ready_to_speak": "Ready to speak.", - "pipeline.vram_low": ( - "{app_name} is running out of VRAM. Check the bottom right of " - "{app_name}'s window to learn more." - ), - "pipeline.close_memory_apps": "Please close any memory-resident applications to improve performance.", - "pipeline.playback_error": "Playback error", - "extensions.autostart_once": "[Core] Cannot autostart Celune extensions more than one time.", - "extensions.autostart_all_deprecated": ( - "CeluneExtensionManager.autostart_all() is deprecated, please use " - "@celune.subscribe('ready') in your extensions instead" - ), - "extensions.autostart_failed": "[Core] Could not autostart {name}: {error}", - "extensions.invoke_failed": "[Core] Failed to invoke '{name}': {error}", - "extensions.folder_not_found": "[Core] Extension folder not found: {path}", - "extensions.unavailable": "Extensions will not be available.", - "extensions.path_not_directory": "[Core] Extension path is not a directory: {path}", - "extensions.spec_load_failed": "[Core] Could not load spec for: {name}", - "extensions.import_failed": "[Core] Failed to import '{name}': {error}", - "extensions.register_failed": "[Core] Failed to register '{name}' from '{file_name}': {error}", - "extensions.not_extension_skipping": "[Core] {file_name} is not a Celune extension, skipping", - "extensions.autostart_deprecated": ( - "CeluneExtension.autostart() is deprecated, please use " - "@celune.subscribe('ready') instead" - ), - "cli.fix_failed": "Failed to fix fixable problems: {error}", - "cli.config_not_created": "{app_name} configuration has not been created yet.", - "cli.run_once_to_create_config": "Run {app_name} at least once to create it.", - "cli.current_config": "Current {app_name} configuration:", - "cli.config_could_not_be_read": "{app_name} configuration could not be read.", - "cli.config_usage": "Usage: {program} config [view/edit]", - "cli.config_description": "View or edit {app_name}'s configuration.", - "cli.no_argument_given": "No argument given.", - "cli.running_from_ide": "{app_name} is running from {ide}.", - "cli.ide_terminals_differ": "Some IDE terminals may behave differently from a normal terminal.", - "cli.config_created": "{app_name} configuration has been created.", - "cli.config_updated_defaults": "{app_name} configuration has been updated with new defaults.", - "cli.launcher_apply_artifact": "{app_name} will close so the launcher can apply the latest artifact.", - "cli.update_info_incomplete": "update information is incomplete", - "cli.update_found": "New update found.", - "cli.update_prompt": "Do you want to update?", - "cli.update_version_summary": ( - "You are running {app_name} {local_version} ({local_revision}), latest " - "version is {latest_label} ({latest_revision})." - ), - "cli.update_choice_yes": "Yes, update now", - "cli.update_choice_no": "No, continue as is", - "cli.updating": "Updating {app_name}...", - "cli.update_failed": "{app_name} could not update: {detail}", - "cli.continuing_current_version": "Continuing with the current version.", - "cli.update_success_restart": "{app_name} updated successfully. Restart {app_name} to apply changes.", - "cli.no_ansi": "This terminal does not support ANSI.", - "cli.request_refresh_binaries": "Requesting the launcher to refresh the packaged binaries...", - "cli.apply_update_noninteractive": "Attempting to apply update non-interactively...", - "cli.not_via_launcher": "{app_name} is not being launched via the {app_name} launcher.", - "cli.suppress_message_run_with": "To suppress this message, run {app_name} with:", - "cli.or_set_env_var": "or set the following environment variable:", - "cli.already_running": "{app_name} is already running.", - "cli.could_not_initialize": "{app_name} could not initialize.", - "cli.running_headless": "{app_name} is running in headless mode.", - "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.cannot_start_normal_mode": "{app_name} cannot start in normal mode.", - "cli.hint": "Hint:", - "cli.try_another_terminal": "Try using another terminal application.", - "cli.internal_error_running": "An internal error occurred while {app_name} was running.", - "cli.no_error_description": "no error description", - "cli.full_traceback_title": "For full traceback:", - "cli.additional_debugging": "additional debugging:", - "cli.set_dev_true": "Set 'dev: true' in config.yaml", - "cli.celine_day_1": "I sense the presence of... her.", - "cli.celine_day_2": "I would rather not.", - "cli.try_again_tomorrow": "Try again tomorrow.", - "cli.apply_update_usage": "Usage: celune __apply_update [args...]", - "cli.invalid_launcher_pid": "Invalid launcher PID.", - "cli.apply_launcher_update_failed": "{app_name} could not apply the launcher update: {error}", - "osc.starting": "{app_name} is starting up...", - "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...", - } -) +STRINGS: dict[str, dict[str, str]] = {} def _normalize_locale_name(locale_name: Optional[str]) -> str: @@ -472,6 +38,40 @@ def _locale_candidates(locale_name: Optional[str]) -> list[str]: 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: """Get the current system locale, falling back to English if unavailable. @@ -486,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() @@ -530,6 +131,7 @@ def string(key: str, locale: Optional[str] = None, **kwargs) -> str: """ 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 diff --git a/celune/lang/en.json b/celune/lang/en.json new file mode 100644 index 0000000..1a3ddb6 --- /dev/null +++ b/celune/lang/en.json @@ -0,0 +1,437 @@ +{ + "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.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.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.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.", + "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\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", + "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.say_placeholder": "Say something...", + "ui.sleeping_log": "{app_name} is currently sleeping. Type anything to wake up.", + "ui.sleeping_status": "Sleeping", + "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/pipeline.py b/celune/pipeline.py index f2a6c3c..46d3cad 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -63,6 +63,7 @@ ) from .cevoice import default_loader from .dsp import ( + pitch_shift_audio, resample_audio, soften, split, @@ -1446,6 +1447,7 @@ def handle_audio_input(engine: Celune, request: AudioInputRequest) -> bool: output.audio, output.sample_rate, output.label, + log_length=request.log_playback, ) engine.log_dev( @@ -1491,7 +1493,7 @@ def convert_audio_input( ) target_references = () - return backend.convert( + output = backend.convert( VoiceConversionRequest( source_audio=np.asarray(request.audio, dtype=np.float32), sample_rate=request.sample_rate, @@ -1499,8 +1501,31 @@ def convert_audio_input( 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( @@ -1628,6 +1653,7 @@ def queue_sfx_audio( label: str, keep: bool = False, volume: float = 1.0, + log_length: bool = True, ) -> bool: """Queue decoded SFX audio through Celune's playback pipeline. @@ -1638,6 +1664,7 @@ 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. Returns: bool: ``True`` when playback was queued successfully, otherwise ``False``. @@ -1646,17 +1673,18 @@ 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( - string( - "pipeline.sample_rate_length", - sample_rate=sample_rate, - seconds=format_number(audio_len, 2), + 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() @@ -1665,8 +1693,8 @@ def queue_sfx_audio( _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( @@ -1680,6 +1708,22 @@ def queue_sfx_audio( 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: @@ -2530,7 +2574,19 @@ def _finalize_playback_idle( and getattr(engine, "loaded", False) and not getattr(engine, "_ready_announced", False) ): - engine.log(string("pipeline.ready_to_speak")) + 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(): diff --git a/celune/typing/celune.py b/celune/typing/celune.py index d3a5be5..9eeb232 100644 --- a/celune/typing/celune.py +++ b/celune/typing/celune.py @@ -19,14 +19,14 @@ 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 ..vc_backends import CeluneVCBackend + from ..backends.vc import CeluneVCBackend type GenerationKwarg = Union[torch.Tensor, int, bool, None] @@ -177,6 +177,11 @@ def __call__(self, progress: Optional[float], total: Optional[float]) -> 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: @@ -192,13 +197,15 @@ 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[Union[str, type["CeluneVCBackend"]]] + _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 diff --git a/celune/ui/app.py b/celune/ui/app.py index e242afb..872f6c0 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 @@ -18,6 +19,9 @@ from dataclasses import dataclass, field from typing import Optional, Callable, Protocol, 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 @@ -56,6 +60,37 @@ discard, ) +_VC_PITCH_SHIFT_MIN = -12 +_VC_PITCH_SHIFT_MAX = 12 +_VC_LIVE_STREAM_CHUNK_SECONDS = 0.75 +_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]", + } +) + + +def _device_scalar_int(value: object, 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: @@ -64,6 +99,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 @@ -137,6 +174,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]]] + ] = 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 @@ -198,6 +249,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") @@ -285,6 +338,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") @@ -391,8 +478,8 @@ def refresh(self, *args, **kwargs) -> object: """Refresh one widget in place. Args: - args: Value for `args`. - kwargs: Value for `kwargs`. + args: Positional refresh arguments accepted by the widget. + kwargs: Keyword refresh arguments accepted by the widget. """ def repaint(widget: _RefreshableWidget) -> None: @@ -585,6 +672,12 @@ def compose(self) -> ComposeResult: with Horizontal(id="controls"): 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") @@ -658,11 +751,14 @@ 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._enable_runtime_log_capture() @@ -673,7 +769,7 @@ def on_mount(self) -> None: ) 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: @@ -702,14 +798,14 @@ def _enable_runtime_log_capture(self) -> None: 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 @@ -734,7 +830,10 @@ def _install_runtime_log_redirects(self) -> None: if self._runtime_redirect_handler is not None: return - handler = UILogHandler(self.safe_log) + 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: @@ -1017,7 +1116,7 @@ def load_tts(self) -> None: self._schedule_sleep_timer() if supports_ansi(self._old_stdout): self.call_from_thread( - self._write_terminal_escape, f"\x1b]2;{APP_NAME}\x07" + lambda: self._write_terminal_escape(f"\x1b]2;{APP_NAME}\x07") ) else: self.cur_state = "error" @@ -1026,7 +1125,13 @@ def load_tts(self) -> None: 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(string("ui.error_signal_unavailable"), "warning") @@ -1081,7 +1186,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 @@ -1194,15 +1299,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.""" @@ -1248,6 +1356,11 @@ def update() -> None: "Please wait" if locked else self._normal_input_placeholder() ) self.style_button.disabled = locked + if self._is_voice_conversion_mode(): + if self.vc_mode_button is not None: + self.vc_mode_button.disabled = locked + if self.vc_pitch_button is not None: + self.vc_pitch_button.disabled = locked self.update_resources() self._run_on_ui_thread(update) @@ -1308,7 +1421,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. @@ -1320,6 +1433,388 @@ 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 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() + 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 + + 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[npt.NDArray[np.float32]], + int, + str, + Optional[ + queue_module.Queue[Optional[tuple[npt.NDArray[np.float32], int, str]]] + ], + 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() + + if stream is not None: + with contextlib.suppress(Exception): + stream.stop() + with contextlib.suppress(Exception): + stream.close() + + return buffered_audio, sample_rate, label, submission_queue, captured_frames + + 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: + _audio, _sample_rate, label, submission_queue, _captured_frames = ( + self._stop_vc_recording_stream() + ) + if submission_queue is not None: + submission_queue.put(None) + + 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: + 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)) + if submission_queue is not None: + submission_queue.put(None) + + 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 self._vc_recording_active(): + return True + + try: + device_info = cast(dict[str, object], sd.query_devices(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, + ) + submission_queue: queue_module.Queue[ + Optional[tuple[npt.NDArray[np.float32], int, str]] + ] = queue_module.Queue() + + def submit_live_audio() -> None: + while True: + item = submission_queue.get() + if item is None: + return + + audio, queued_sample_rate, queued_label = item + try: + if self.celune is None: + continue + if not self.celune.submit_audio( + audio, + queued_sample_rate, + label=queued_label, + log_playback=False, + ): + self.safe_log( + string("ui.recording_stream_submit_failed"), + "warning", + ) + 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: object, + status: object, + ) -> 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_buffer_locked() + if buffered_audio is not None: + self._vc_recording_submission_queue.put( + (buffered_audio, sample_rate, label) + ) + + 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, + ) + 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: + ( + 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)) + if submission_queue is not None: + submission_queue.put(None) + + 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. @@ -1336,11 +1831,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) @@ -1409,13 +1906,13 @@ def _submit_text(self, text: str, process_commands: bool = True) -> bool: 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("") @@ -1429,7 +1926,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: @@ -1560,7 +2060,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: @@ -1568,7 +2068,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)) @@ -1576,7 +2076,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() @@ -1623,6 +2125,12 @@ def on_key(self, event: events.Key) -> None: event.prevent_default() return + if event.key == "ctrl+r": + 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() @@ -1639,6 +2147,22 @@ 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 @@ -1665,6 +2189,7 @@ def on_unmount(self) -> None: 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() @@ -1686,20 +2211,20 @@ def tts_idle(self) -> None: self.change_input_state(locked=True) self.change_voice_lock_state(locked=True) if self.celune.cur_state == "waking": - self.safe_status("Waking up") + 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( @@ -1710,9 +2235,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) @@ -1797,6 +2322,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 d6ead0f..602a013 100644 --- a/celune/ui/commands.py +++ b/celune/ui/commands.py @@ -13,7 +13,7 @@ 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 @@ -158,6 +158,43 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None: """ ui.input_box.load_text("") + + 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( + string( + "commands.vcmode_set", + mode=string("ui.vc_mode_sing" if enabled else "ui.vc_mode_talk"), + ) + ) + + def set_vc_pitch_shift(value: int) -> None: + setter = getattr(ui, "set_vc_pitch_shift", None) + if callable(setter): + setter(value) + return + + 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")) @@ -175,6 +212,10 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None: 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(string("commands.help_xvectoronly")) @@ -318,7 +359,16 @@ def process_command(ui: CeluneUI, command: str, args: list[str]) -> None: backend_name = args[0] - if backend_name == ui.celune.backend.name: + 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: + 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 @@ -372,6 +422,102 @@ def cevoice_worker() -> None: 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): diff --git a/celune/ui/resources.py b/celune/ui/resources.py index 0f15537..68cb832 100644 --- a/celune/ui/resources.py +++ b/celune/ui/resources.py @@ -154,6 +154,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("CTRL+R 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 a3cc047..094e651 100644 --- a/celune/ui/terminal.py +++ b/celune/ui/terminal.py @@ -94,6 +94,15 @@ 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.""" @@ -131,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\\-_])" @@ -151,7 +156,7 @@ def write(self, text: str) -> None: chunk = self._buffer[:pos].strip() self._buffer = self._buffer[pos + 1 :] - if chunk: + if chunk and not self._is_filtered_message(chunk): self.write_callback( chunk, self._severity_for_message(chunk, self.default_severity), @@ -176,10 +181,11 @@ def flush(self) -> None: """Flush the buffers.""" if self._buffer.strip(): chunk = self._buffer.strip() - self.write_callback( - chunk, - self._severity_for_message(chunk, self.default_severity), - ) + 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: @@ -194,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. @@ -219,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:" 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/vram.py b/celune/vram.py index 4396974..d4e2ac0 100644 --- a/celune/vram.py +++ b/celune/vram.py @@ -14,13 +14,12 @@ 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..e2b2f6c 100644 --- a/default_config.yaml +++ b/default_config.yaml @@ -24,6 +24,14 @@ voice_bundle: default # and auto-voice analysis upon end of utterance dev: false +# Voice conversion 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 + # headless mode / Celune embedded framework # # this will disable the UI and only API and extensions can make diff --git a/pyproject.toml b/pyproject.toml index d173acf..aa012bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] @@ -125,11 +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 = [ + # '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/scripts/run_ci.py b/scripts/run_ci.py index 6d9abfa..5b6499d 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,11 +19,20 @@ 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: @@ -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 9fb0a1d..f6eef27 100644 --- a/tests/support.py +++ b/tests/support.py @@ -18,8 +18,8 @@ import numpy.typing as npt from celune.utils import discard -from celune.backends.base import CeluneBackend -from celune.vc_backends.base import CeluneVCBackend +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 @@ -326,7 +326,7 @@ class StubQwen3TTS: ) }, ): - qwen3 = importlib.import_module("celune.backends.qwen3") + qwen3 = importlib.import_module("celune.backends.tts.qwen3") yield qwen3.Qwen3 @@ -341,7 +341,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 @@ -365,7 +365,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 @@ -380,5 +380,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 72c1433..96b4be7 100644 --- a/tests/test_api_audio.py +++ b/tests/test_api_audio.py @@ -160,7 +160,7 @@ def test_convert_route_rejects_requests_outside_voice_conversion_mode(self) -> N 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 from the engine.""" + """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 @@ -194,12 +194,12 @@ def test_convert_route_returns_converted_audio(self) -> None: api.bound_celune = previous_celune self.assertEqual(response.status_code, 200) - self.assertEqual(response.headers["x-sample-rate"], "24000") + 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, 24000) - self.assertEqual(decoded_audio.shape, (12, 2)) + 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 2ca9fe4..1501f16 100644 --- a/tests/test_api_webui.py +++ b/tests/test_api_webui.py @@ -11,6 +11,7 @@ from celune import api from celune.celune import Celune +from celune.i18n import string from celune.pipeline import SpeechStreamQueue @@ -184,7 +185,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 +204,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 +215,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 +297,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( @@ -449,7 +517,7 @@ def say_stream(content: str, save: bool = True) -> SpeechStreamQueue: 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 browser payload.""" + """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, @@ -476,14 +544,25 @@ def test_webui_convert_audio_returns_browser_audio_after_conversion(self) -> Non 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)) + (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, 24000) - self.assertEqual(array.shape, (10, 2)) + 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_rejects_text_to_speech_mode(self) -> None: """Verify browser audio conversion is unavailable outside VC mode.""" @@ -513,6 +592,18 @@ def test_webui_convert_audio_rejects_text_to_speech_mode(self) -> None: self.assertIsNone(browser_audio) self.assertIn("voice conversion 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, ["TTS", "VC"]) + 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( @@ -541,6 +632,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("CTRL+R 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 73d1312..15ecb38 100644 --- a/tests/test_backends_and_extensions.py +++ b/tests/test_backends_and_extensions.py @@ -9,9 +9,10 @@ import contextlib from pathlib import Path 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 @@ -20,10 +21,11 @@ from celune.utils import discard from celune.celune import Celune -from celune.backends import resolve_backend +from celune.backends.tts import resolve_backend from celune.typing.backends import BackendModel -from celune.vc_backends import resolve_vc_backend -from celune.vc_backends.passthrough import CelunePassthroughVCBackend +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 @@ -71,7 +73,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() @@ -94,7 +96,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) @@ -183,6 +185,247 @@ def test_passthrough_vc_backend_returns_playable_output(self) -> None: 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: Value for `kwargs`. + + Returns: + Result of this function. + """ + 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_leaves_pitch_shift_to_the_shared_vc_pipeline( + self, + ) -> None: + """Verify Seed-VC keeps wrapper pitch shifting disabled for shared output handling.""" + 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: Value for `kwargs`. + + Returns: + Result of this function. + """ + 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"], 0) + + 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: Value for `kwargs`. + + Returns: + Result of this function. + """ + 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_app_data( + self, + ) -> None: + """Verify Celune overrides Seed-VC's repo-local checkpoint cache path.""" + 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) / "checkpoints" + + with ( + mock.patch( + "celune.backends.vc.seedvc.app_data_dir", + return_value=Path(temp_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.""" @@ -193,7 +436,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"): @@ -245,7 +488,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) @@ -267,7 +510,7 @@ 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( FileNotFoundError, "requires a compatible CEVOICE/CECHAR package" @@ -303,7 +546,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") @@ -327,7 +570,7 @@ 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( FileNotFoundError, "requires a compatible CEVOICE/CECHAR package" @@ -339,7 +582,9 @@ 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( FileNotFoundError, "requires a compatible CEVOICE/CECHAR package" ): @@ -386,7 +631,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") ), @@ -405,7 +652,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", @@ -420,7 +669,7 @@ 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( FileNotFoundError, "requires a compatible CEVOICE/CECHAR package" @@ -459,7 +708,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() @@ -496,7 +747,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") ), @@ -541,7 +794,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) @@ -580,7 +833,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") @@ -626,7 +879,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) @@ -642,7 +895,7 @@ 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( FileNotFoundError, "requires a compatible CEVOICE/CECHAR package" @@ -654,7 +907,7 @@ 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( FileNotFoundError, "requires a compatible CEVOICE/CECHAR package" @@ -717,7 +970,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) @@ -736,7 +989,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 @@ -749,7 +1002,7 @@ 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( FileNotFoundError, "requires a compatible CEVOICE/CECHAR package" @@ -761,7 +1014,7 @@ 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( FileNotFoundError, "requires a compatible CEVOICE/CECHAR package" @@ -781,7 +1034,7 @@ 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( FileNotFoundError, "requires a compatible CEVOICE/CECHAR package" @@ -843,7 +1096,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() @@ -892,7 +1147,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( @@ -933,7 +1190,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) 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 1639781..406b4a5 100644 --- a/tests/test_celune_core.py +++ b/tests/test_celune_core.py @@ -17,7 +17,7 @@ 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 ( convert_audio_input, @@ -111,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. @@ -780,8 +859,76 @@ def test_convert_audio_returns_vc_output_without_queueing_playback(self) -> None 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({}) + 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.convert_audio_input", return_value=None + ) as convert_input: + celune.convert_audio(audio, 32000, label="fixture", pitch_shift=7) + + submitted_request = convert_input.call_args.args[1] + self.assertEqual(submitted_request.pitch_shift, 7) + + def test_convert_audio_accepts_f0_condition_override(self) -> None: + """Verify direct conversion forwards one f0 conditioning override.""" + celune = self._make_celune({}) + 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.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.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_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({}) @@ -1018,14 +1165,140 @@ 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.tts_backend = "mini" 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, @@ -1047,7 +1320,7 @@ def test_set_backend_rejects_unknown_backend_before_reload_side_effects( 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)", + "Unknown backend: qwen (available: mini, qwen3, dotstts, voxcpm2, passthrough, seed-vc)", "warning", ) self.assertEqual(celune.cur_state, "idle") 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_pipeline.py b/tests/test_pipeline.py index 62ffa05..7c6cf30 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -326,6 +326,8 @@ def test_handle_audio_input_routes_to_vc_backend_in_voice_conversion_mode( 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) @@ -356,6 +358,79 @@ def test_handle_audio_input_reports_missing_vc_backend_cleanly(self) -> None: ) 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() diff --git a/tests/test_runtime_and_ui_commands.py b/tests/test_runtime_and_ui_commands.py index 7a9e771..96fbc7e 100644 --- a/tests/test_runtime_and_ui_commands.py +++ b/tests/test_runtime_and_ui_commands.py @@ -2,14 +2,16 @@ """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 Callable, Optional, cast from pathlib import Path from types import SimpleNamespace from unittest import mock, TestCase +import numpy as np from textual import events from textual.widgets import Button, Label, RichLog, TextArea @@ -17,8 +19,9 @@ 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.app import CeluneUI from celune.ui.headless import CeluneHeadlessUI from celune.ui import resources as ui_resources @@ -26,7 +29,7 @@ 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): @@ -177,6 +180,7 @@ 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=FakeBackend, @@ -190,6 +194,10 @@ def setUp(self) -> None: 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: @@ -258,6 +266,143 @@ def test_backend_and_cevoice_commands_request_hot_reloads(self) -> None: 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) @@ -442,6 +587,8 @@ def test_textual_ui_mount_enables_stdio_redirects_before_runtime_load(self) -> N "#status": Label(), "#resources": Label(), "#style": Button(), + "#vc-mode": Button(), + "#vc-pitch": Button(), "#progress": SimpleNamespace(update=lambda **_: None), "#header": Label(), } @@ -584,6 +731,40 @@ def test_log_redirect_reclassifies_warning_like_stdout_lines(self) -> None: ], ) + 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() @@ -761,6 +942,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", ), ) @@ -768,6 +950,216 @@ 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(), + submit_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[ + Callable[[np.ndarray, int, object, object], None] + ] = None + + def invoke_captured_callback( + callback: Callable[[np.ndarray, int, object, object], None], + 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", + }, + ), + 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)) + + if captured_callback is None or not callable(captured_callback): + self.fail("recording callback was not registered") + else: + invoke_captured_callback( + cast( + Callable[[np.ndarray, int, object, object], None], + captured_callback, + ), + np.ones((48000, 2), dtype=np.float32), + ) + for _ in range(50): + if cast(mock.Mock, ui.celune.submit_audio).call_count >= 1: + break + time.sleep(0.01) + invoke_captured_callback( + cast( + Callable[[np.ndarray, int, object, object], None], + 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)) + + for _ in range(50): + if cast(mock.Mock, ui.celune.submit_audio).call_count >= 2: + break + time.sleep(0.01) + + self.assertGreaterEqual(cast(mock.Mock, ui.celune.submit_audio).call_count, 2) + submit_calls = cast(mock.Mock, ui.celune.submit_audio).call_args_list + first_submit_args = submit_calls[0].args + final_submit_args = submit_calls[-1].args + self.assertEqual(first_submit_args[1], 48000) + self.assertEqual(final_submit_args[1], 48000) + self.assertEqual( + submit_calls[-1].kwargs["label"], + "Stereo Mix", + ) + self.assertEqual(submit_calls[0].kwargs["log_playback"], False) + self.assertEqual(submit_calls[-1].kwargs["log_playback"], False) + self.assertGreaterEqual(len(first_submit_args[0]), 36000) + self.assertEqual(len(final_submit_args[0]), 8) + + 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(), + submit_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[ + Callable[[np.ndarray, int, object, object], None] + ] = 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: object, + _status: object, + ) -> 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_gpu_usage_handles_closed_stdout_pipe(self) -> None: """Verify resource polling ignores closed-pipe nvidia-smi failures.""" @@ -813,7 +1205,7 @@ 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() @@ -837,7 +1229,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, @@ -850,7 +1242,29 @@ def test_placeholder_uses_loaded_persona_not_runtime_capability(self) -> None: ), ) 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=object(), + ), + ) + + self.assertEqual( + ui.normal_input_placeholder(), + string("ui.voice_changer_placeholder"), + ) def test_runtime_logger_warning_is_routed_into_ui_logs(self) -> None: """Verify external Python logger warnings are routed into the UI logs.""" @@ -947,6 +1361,21 @@ def test_runtime_global_log_redirect_captures_unlisted_external_logger( [("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.""" diff --git a/uv.lock b/uv.lock index bca9353..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" @@ -335,8 +353,6 @@ 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" From 2c475af1369ba8d15384817f82130ee1c677ec1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?-=20=F0=9D=98=AD=20=F0=9D=98=B6=20=F0=9D=98=AF=20?= =?UTF-8?q?=F0=9D=98=A2=20=F0=9D=98=A9=20-?= <71488561+celunah@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:16:15 +0200 Subject: [PATCH 16/26] chore(ci): install all package groups because all Celune backends being grouped into their own optional package groups, we have to explicitly tell CI to install the whole thing --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 . From 2247c3dd3b18c8e93e400c7cced09d878158ff92 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Sat, 27 Jun 2026 12:26:36 +0200 Subject: [PATCH 17/26] chore: batch fix actionable --- AGENTS.md | 2 +- celune/api.py | 9 ++++-- celune/backends/tts/__init__.py | 11 ++++--- celune/backends/tts/dotstts.py | 13 ++++++--- celune/backends/tts/voxcpm2.py | 13 ++++++--- celune/backends/vc/seedvc.py | 13 ++++----- celune/celune.py | 2 +- celune/dsp.py | 8 +++--- celune/lang/en.json | 6 ++++ celune/ui/app.py | 41 ++++++++++++++++++++------- celune/ui/resources.py | 3 +- scripts/run_ci.py | 4 +-- tests/test_api_webui.py | 9 ++++-- tests/test_backends_and_extensions.py | 19 ++++++++++--- 14 files changed, 106 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 147b568..f4da79b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ Do not use raw string literals in the code. Always use `string("key_name", **kwa 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. +Make sure to only modify user-facing strings (both normal and dev mode strings), don't change anything internal. ## Python and Environment diff --git a/celune/api.py b/celune/api.py index 60ae155..6c0863e 100644 --- a/celune/api.py +++ b/celune/api.py @@ -1054,7 +1054,8 @@ def _webui_resources_html() -> str: def _webui_shortcuts_html() -> str: """Render browser-side keyboard shortcuts for WebUI-only controls.""" - return textwrap.dedent( + 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: diff --git a/celune/backends/tts/__init__.py b/celune/backends/tts/__init__.py index 58ead6e..91baffe 100644 --- a/celune/backends/tts/__init__.py +++ b/celune/backends/tts/__init__.py @@ -6,6 +6,7 @@ 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"] @@ -71,13 +72,15 @@ def resolve_backend( module_name, class_name = BACKENDS[key] except KeyError as e: raise ValueError( - f"unknown backend: '{backend_name}' (available: {', '.join(BACKENDS.keys())})" + 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( - "'backend_name' must be a backend instance, backend type, or backend name string" - ) + raise TypeError(string("celune.invalid_backend_type")) diff --git a/celune/backends/tts/dotstts.py b/celune/backends/tts/dotstts.py index 06746e0..82ec285 100644 --- a/celune/backends/tts/dotstts.py +++ b/celune/backends/tts/dotstts.py @@ -15,6 +15,7 @@ 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 @@ -95,8 +96,10 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]: custom_assert( loader is not None, FileNotFoundError( - "backend 'dotstts' requires a compatible CEVOICE/CECHAR package " - "with at least one valid voice identifier" + string( + "celune.compatible_bundle_required", + backend="dotstts", + ) ), ) assert loader is not None @@ -115,8 +118,10 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]: custom_assert( bool(voice_names), FileNotFoundError( - "backend 'dotstts' requires a compatible CEVOICE/CECHAR package " - "with at least one valid voice identifier" + string( + "celune.compatible_bundle_required", + backend="dotstts", + ) ), ) assert bool(voice_names) diff --git a/celune/backends/tts/voxcpm2.py b/celune/backends/tts/voxcpm2.py index ca90c3f..c4ffd8a 100644 --- a/celune/backends/tts/voxcpm2.py +++ b/celune/backends/tts/voxcpm2.py @@ -15,6 +15,7 @@ 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,8 +88,10 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]: custom_assert( loader is not None, FileNotFoundError( - "backend 'voxcpm2' requires a compatible CEVOICE/CECHAR package " - "with at least one valid voice identifier" + string( + "celune.compatible_bundle_required", + backend="voxcpm2", + ) ), ) assert loader is not None @@ -107,8 +110,10 @@ def _require_compatible_bundle() -> tuple[CEVoiceLoader, tuple[str, ...]]: custom_assert( bool(voice_names), FileNotFoundError( - "backend 'voxcpm2' requires a compatible CEVOICE/CECHAR package " - "with at least one valid voice identifier" + string( + "celune.compatible_bundle_required", + backend="voxcpm2", + ) ), ) assert bool(voice_names) diff --git a/celune/backends/vc/seedvc.py b/celune/backends/vc/seedvc.py index 088529c..c9ac8f6 100644 --- a/celune/backends/vc/seedvc.py +++ b/celune/backends/vc/seedvc.py @@ -17,6 +17,7 @@ from .base import CeluneVCBackend from ...dataclasses.pipeline import AudioOutput, VoiceConversionRequest +from ...i18n import string from ...paths import app_data_dir __all__ = ["CeluneSeedVCBackend"] @@ -133,7 +134,7 @@ def _load_wrapper_type(cls) -> type: wrapper_module = importlib.import_module("seed_vc.seed_vc_wrapper") except ImportError as e: raise ImportError( - "Seed-VC backend requires the 'seed_vc' package to be installed" + string("seedvc.package_required", package="seed_vc") ) from e cls._configure_seedvc_downloads(hf_utils_module, wrapper_module) @@ -144,7 +145,7 @@ def _get_wrapper(self): with self._wrapper_lock: if self._wrapper is None: wrapper_type = self._load_wrapper_type() - self.log("Loading Seed-VC models...", "info") + self.log(string("seedvc.loading_models"), "info") self._wrapper = wrapper_type() return self._wrapper @@ -174,7 +175,7 @@ def output_sample_rate(self) -> int: """Return the sample rate produced by the backend's default Seed-VC mode. Returns: - Result of this function. + int: ``44100`` when f0 conditioning is enabled, otherwise ``22050``. """ return 44100 if self.f0_condition else 22050 @@ -215,9 +216,7 @@ def convert(self, request: VoiceConversionRequest) -> AudioOutput: ValueError: The request does not include a target reference WAV. """ if not request.target_references: - raise ValueError( - "Seed-VC requires at least one target reference WAV for conversion" - ) + raise ValueError(string("seedvc.target_reference_required")) target_reference = request.target_references[0] wrapper = self._get_wrapper() @@ -234,7 +233,7 @@ def convert(self, request: VoiceConversionRequest) -> AudioOutput: inference_cfg_rate=self.inference_cfg_rate, f0_condition=f0_condition, auto_f0_adjust=self.auto_f0_adjust, - pitch_shift=0, + pitch_shift=self.pitch_shift, stream_output=False, ) ) diff --git a/celune/celune.py b/celune/celune.py index 8cc3bbf..a5b0a4f 100644 --- a/celune/celune.py +++ b/celune/celune.py @@ -728,7 +728,7 @@ def is_voice_conversion_mode(self) -> bool: """Public interface for Celune._is_voice_conversion_mode. Returns: - Result of this function. + bool: Whether this Celune instance is currently in voice conversion mode. """ return self._is_voice_conversion_mode() diff --git a/celune/dsp.py b/celune/dsp.py index 0842a52..81af75c 100644 --- a/celune/dsp.py +++ b/celune/dsp.py @@ -87,12 +87,12 @@ def pitch_shift_audio( """Shift audio pitch while preserving tempo at the given sample rate. Args: - audio: Value for `audio`. - sample_rate: Value for `sample_rate`. - n_steps: Value for `n_steps`. + 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: - Result of this function. + 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) diff --git a/celune/lang/en.json b/celune/lang/en.json index 1a3ddb6..62394ba 100644 --- a/celune/lang/en.json +++ b/celune/lang/en.json @@ -21,6 +21,7 @@ "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.", @@ -35,6 +36,7 @@ "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.", @@ -373,6 +375,9 @@ "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}", @@ -392,6 +397,7 @@ "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", diff --git a/celune/ui/app.py b/celune/ui/app.py index 872f6c0..2a38e45 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -1575,6 +1575,7 @@ def _clear_vc_recording_state(self) -> None: def _stop_vc_recording_stream( self, ) -> tuple[ + Optional[sd.InputStream], Optional[npt.NDArray[np.float32]], int, str, @@ -1591,14 +1592,25 @@ def _stop_vc_recording_stream( 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, + ) - if stream is not None: - with contextlib.suppress(Exception): - stream.stop() - with contextlib.suppress(Exception): - stream.close() + @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 - return buffered_audio, sample_rate, label, submission_queue, captured_frames + 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.""" @@ -1606,11 +1618,12 @@ def _cancel_vc_recording(self, announce: bool = True) -> bool: return False with self._vc_recording_lock: - _audio, _sample_rate, label, submission_queue, _captured_frames = ( + 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") @@ -1622,13 +1635,19 @@ def _stop_vc_recording_for_feedback(self) -> None: return with self._vc_recording_lock: - buffered_audio, sample_rate, label, submission_queue, _captured_frames = ( - self._stop_vc_recording_stream() - ) + ( + 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)) 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() @@ -1795,6 +1814,7 @@ def toggle_vc_recording(self) -> bool: if self._vc_recording_active(): with self._vc_recording_lock: ( + stream, buffered_audio, sample_rate, label, @@ -1805,6 +1825,7 @@ def toggle_vc_recording(self) -> bool: submission_queue.put((buffered_audio, sample_rate, label)) 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() diff --git a/celune/ui/resources.py b/celune/ui/resources.py index 68cb832..4a42103 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: @@ -155,7 +156,7 @@ 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("CTRL+R toggle recording") + pages.append(string("ui.footer_toggle_recording")) active_theme = theme_name enter_action = "skip" if celune.is_in_tutorial else "say" diff --git a/scripts/run_ci.py b/scripts/run_ci.py index 5b6499d..bee5576 100644 --- a/scripts/run_ci.py +++ b/scripts/run_ci.py @@ -19,11 +19,11 @@ cmds_failed = 0 total_errors = [] -_AGENT_ERROR_MARKERS = { +_AGENT_ERROR_MARKERS = ( "Access is denied", "Permission denied", "Operation not permitted", -} +) def _agent_permission_marker(output: str) -> Optional[str]: diff --git a/tests/test_api_webui.py b/tests/test_api_webui.py index 1501f16..05ecab6 100644 --- a/tests/test_api_webui.py +++ b/tests/test_api_webui.py @@ -590,7 +590,7 @@ def test_webui_convert_audio_rejects_text_to_speech_mode(self) -> None: self.assertIsNotNone(source_value) self.assertEqual(source_value[0], 44100) self.assertIsNone(browser_audio) - self.assertIn("voice conversion mode", logs_html) + 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.""" @@ -602,7 +602,10 @@ def test_build_webui_exposes_tts_and_vc_tabs(self) -> None: if component.get("type") == "tabitem" ] - self.assertEqual(tab_labels, ["TTS", "VC"]) + 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.""" @@ -636,7 +639,7 @@ 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("CTRL+R toggle recording", shortcuts_html) + self.assertIn(string("ui.footer_toggle_recording"), shortcuts_html) self.assertIn("#celune-source-audio", shortcuts_html) self.assertIn("keydown", shortcuts_html) diff --git a/tests/test_backends_and_extensions.py b/tests/test_backends_and_extensions.py index 15ecb38..e5d9802 100644 --- a/tests/test_backends_and_extensions.py +++ b/tests/test_backends_and_extensions.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: MIT """Tests for backend resolution and extension infrastructure.""" +import re import sys import tempfile import textwrap @@ -22,6 +23,7 @@ from celune.utils import discard 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 @@ -117,7 +119,16 @@ 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] @@ -261,10 +272,10 @@ def result_generator() -> Generator[ self.assertEqual(captured["f0_condition"], False) self.assertEqual(captured["pitch_shift"], 0) - def test_seedvc_backend_leaves_pitch_shift_to_the_shared_vc_pipeline( + def test_seedvc_backend_uses_configured_pitch_shift_for_wrapper_requests( self, ) -> None: - """Verify Seed-VC keeps wrapper pitch shifting disabled for shared output handling.""" + """Verify Seed-VC forwards its configured pitch shift into wrapper requests.""" backend = CeluneSeedVCBackend( log=lambda _msg, _severity="info": None, pitch_shift=-6, @@ -309,7 +320,7 @@ def result_generator() -> Generator[ ) ) - self.assertEqual(captured["pitch_shift"], 0) + self.assertEqual(captured["pitch_shift"], -6) def test_seedvc_backend_prefers_request_f0_condition_over_backend_default( self, From 51029535aa144e59e3d6713c1814a4b369a6ebd3 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Sat, 27 Jun 2026 13:36:27 +0200 Subject: [PATCH 18/26] fix: unexpected auth, VC sounds bad in Gradio --- celune/api.py | 47 ++++++++++++++-- tests/test_api_webui.py | 121 ++++++++++++++++++++++++++++++++-------- 2 files changed, 140 insertions(+), 28 deletions(-) diff --git a/celune/api.py b/celune/api.py index 6c0863e..7ce2e88 100644 --- a/celune/api.py +++ b/celune/api.py @@ -75,7 +75,8 @@ WebUiUpdate = dict[str, JSONSerializable] WebUiAudioValue = Optional[tuple[int, npt.NDArray[np.float32]]] -WebUiInputAudioValue = Optional[tuple[int, npt.NDArray[np.float32]]] +WebUiInputArray = Union[npt.NDArray[np.float32], npt.NDArray[np.int16]] +WebUiInputAudioValue = Optional[tuple[int, WebUiInputArray]] class _WebUiUnset: @@ -502,12 +503,28 @@ def _authenticated(request: Request) -> bool: return given is not None and compare_digest(given, auth_token) -def _is_browser_ui_request(request: Request) -> bool: +def is_browser_ui_request(request: Request) -> bool: """Return whether the request targets the mounted browser UI.""" 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: @@ -550,7 +567,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): @@ -1245,7 +1262,7 @@ def _webui_vc_controls_update() -> tuple[ """Return the current browser VC control state.""" celune = bound_celune vc_enabled = _webui_vc_mode_active(celune) - control_interactive = bool(celune is not None and vc_enabled) + control_interactive = celune is not None and vc_enabled return ( gr.update(interactive=control_interactive), gr.update(interactive=control_interactive), @@ -1337,6 +1354,23 @@ def _decode_uploaded_audio( 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( @@ -1483,7 +1517,9 @@ def _webui_convert_audio( send_update, ) - sample_rate, audio = source_audio + 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( @@ -1543,7 +1579,6 @@ def _webui_convert_audio( 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 diff --git a/tests/test_api_webui.py b/tests/test_api_webui.py index 05ecab6..3eb5579 100644 --- a/tests/test_api_webui.py +++ b/tests/test_api_webui.py @@ -3,11 +3,13 @@ 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 @@ -32,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 @@ -60,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( + @staticmethod + def _request(path: str, method: str = "GET") -> Request: + """Build one lightweight test request.""" + return 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( - { - "type": "http", - "method": "GET", - "path": "/v1/version", + "method": method, + "path": path, "headers": [], "query_string": b"", "client": ("127.0.0.1", 2060), @@ -96,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( @@ -564,6 +595,52 @@ def test_webui_convert_audio_returns_browser_audio_after_conversion(self) -> Non 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( From 152b850ccf2a877390874e13a125e0af30ea456c Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Sat, 27 Jun 2026 17:23:19 +0200 Subject: [PATCH 19/26] feat: let Celune choose specified input/output audio devices, and fix Seed-VC sleep double reload bug --- celune/celune.py | 4 + celune/config.py | 118 +++++++++++++++++++++++++- celune/dataclasses/pipeline.py | 1 + celune/lang/en.json | 3 + celune/pipeline.py | 38 ++++++++- celune/ui/app.py | 52 +++++++++++- celune/ui/resources.py | 1 + default_config.yaml | 17 +++- tests/test_celune_core.py | 1 + tests/test_config_and_utils.py | 104 +++++++++++++++++++++++ tests/test_pipeline.py | 83 ++++++++++++++++++ tests/test_runtime_and_ui_commands.py | 60 ++++++++++++- 12 files changed, 472 insertions(+), 10 deletions(-) diff --git a/celune/celune.py b/celune/celune.py index a5b0a4f..fa77ab3 100644 --- a/celune/celune.py +++ b/celune/celune.py @@ -2783,6 +2783,7 @@ def submit_audio( 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. @@ -2793,6 +2794,8 @@ def submit_audio( 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. @@ -2806,6 +2809,7 @@ def submit_audio( pitch_shift=pitch_shift, f0_condition=f0_condition, log_playback=log_playback, + reset_ready_announcement=reset_ready_announcement, ), ) diff --git a/celune/config.py b/celune/config.py index 6f1b16f..d95d325 100644 --- a/celune/config.py +++ b/celune/config.py @@ -3,12 +3,18 @@ import os from copy import deepcopy -from typing import Optional +from typing import Optional, Union 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 = str def env_bool(name: str, fallback: bool = False) -> bool: @@ -68,6 +74,116 @@ 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.""" + 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, object]]]: + """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, object]]]: 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/dataclasses/pipeline.py b/celune/dataclasses/pipeline.py index fbebe9d..b933a67 100644 --- a/celune/dataclasses/pipeline.py +++ b/celune/dataclasses/pipeline.py @@ -38,6 +38,7 @@ class AudioInputRequest: pitch_shift: Optional[int] = None f0_condition: Optional[bool] = None log_playback: bool = True + reset_ready_announcement: bool = True @dataclass(frozen=True) diff --git a/celune/lang/en.json b/celune/lang/en.json index 62394ba..7032c2d 100644 --- a/celune/lang/en.json +++ b/celune/lang/en.json @@ -106,6 +106,9 @@ "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.", diff --git a/celune/pipeline.py b/celune/pipeline.py index 46d3cad..7dc162a 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -40,6 +40,7 @@ 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 @@ -530,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]: @@ -1448,6 +1459,7 @@ def handle_audio_input(engine: Celune, request: AudioInputRequest) -> bool: output.sample_rate, output.label, log_length=request.log_playback, + reset_ready_announcement=request.reset_ready_announcement, ) engine.log_dev( @@ -1654,6 +1666,7 @@ def queue_sfx_audio( 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. @@ -1665,6 +1678,7 @@ def queue_sfx_audio( 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``. @@ -1689,7 +1703,10 @@ def queue_sfx_audio( 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 @@ -2498,18 +2515,35 @@ 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( diff --git a/celune/ui/app.py b/celune/ui/app.py index 2a38e45..8c9d02e 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -38,7 +38,9 @@ from .. import colors from ..celune import Celune 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, is_celune_log_record from ..paths import config_path, main_window_log_path @@ -62,7 +64,7 @@ _VC_PITCH_SHIFT_MIN = -12 _VC_PITCH_SHIFT_MAX = 12 -_VC_LIVE_STREAM_CHUNK_SECONDS = 0.75 +_VC_LIVE_STREAM_CHUNK_SECONDS = 1.0 _VC_FEEDBACK_RMS_MIN_PREVIOUS = 0.05 _VC_FEEDBACK_RMS_MIN_CURRENT = 0.18 _VC_FEEDBACK_RMS_RISE_RATIO = 2.0 @@ -763,7 +765,7 @@ def on_mount(self) -> None: self._refresh_logs() 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 ) @@ -1656,12 +1658,42 @@ 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, object], sd.query_devices(kind="input")) + device_info = ( + cast(dict[str, object], dict(direct_device_info)) + if direct_device_info is not None + else cast( + dict[str, object], + sd.query_devices(device=input_device, kind="input"), + ) + ) except Exception as e: self.safe_log( string( @@ -1706,6 +1738,7 @@ def submit_live_audio() -> None: queued_sample_rate, label=queued_label, log_playback=False, + reset_ready_announcement=False, ): self.safe_log( string("ui.recording_stream_submit_failed"), @@ -1774,6 +1807,7 @@ def callback( channels=channel_count, dtype="float32", callback=callback, + device=input_device, ) stream.start() except Exception as e: @@ -2147,6 +2181,18 @@ def on_key(self, event: events.Key) -> None: 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() diff --git a/celune/ui/resources.py b/celune/ui/resources.py index 4a42103..035aef6 100644 --- a/celune/ui/resources.py +++ b/celune/ui/resources.py @@ -20,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: diff --git a/default_config.yaml b/default_config.yaml index e2b2f6c..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,7 +27,7 @@ voice_bundle: default # and auto-voice analysis upon end of utterance dev: false -# Voice conversion settings +# voice conversion default settings # # positive values raise the converted voice, negative values lower it # useful for helping cross-gender conversions land more naturally @@ -32,6 +35,16 @@ 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/tests/test_celune_core.py b/tests/test_celune_core.py index 406b4a5..f7ba7dc 100644 --- a/tests/test_celune_core.py +++ b/tests/test_celune_core.py @@ -808,6 +808,7 @@ def test_submit_audio_is_accepted_and_does_not_use_tts(self) -> None: 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", 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_pipeline.py b/tests/test_pipeline.py index 7c6cf30..3199e83 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -756,6 +756,89 @@ def test_playback_worker_mixes_sources_and_glow_receives_mixed_audio(self) -> No 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: diff --git a/tests/test_runtime_and_ui_commands.py b/tests/test_runtime_and_ui_commands.py index 96fbc7e..d59774f 100644 --- a/tests/test_runtime_and_ui_commands.py +++ b/tests/test_runtime_and_ui_commands.py @@ -980,6 +980,7 @@ def test_ctrl_r_toggles_tui_vc_recording(self) -> None: submit_audio=mock.Mock(return_value=True), is_in_tutorial=False, dev=False, + config={"input_device": "Stereo Mix (Realtek)"}, ), ) ui.safe_log = mock.Mock() @@ -1017,8 +1018,11 @@ def __init__(self, **kwargs) -> None: "default_samplerate": 48000, "name": "Stereo Mix", }, - ), - mock.patch("celune.ui.app.sd.InputStream", side_effect=FakeInputStream), + ) as mock_query_devices, + mock.patch( + "celune.ui.app.sd.InputStream", + side_effect=FakeInputStream, + ) as mock_input_stream, ): start_event = SimpleNamespace( key="ctrl+r", @@ -1056,6 +1060,14 @@ def __init__(self, **kwargs) -> None: ) 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.submit_audio).call_count >= 2: break @@ -1073,6 +1085,14 @@ def __init__(self, **kwargs) -> None: ) self.assertEqual(submit_calls[0].kwargs["log_playback"], False) self.assertEqual(submit_calls[-1].kwargs["log_playback"], False) + self.assertEqual( + submit_calls[0].kwargs["reset_ready_announcement"], + False, + ) + self.assertEqual( + submit_calls[-1].kwargs["reset_ready_announcement"], + False, + ) self.assertGreaterEqual(len(first_submit_args[0]), 36000) self.assertEqual(len(final_submit_args[0]), 8) @@ -1161,6 +1181,42 @@ def missing_callback( ) ) + 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.""" proc = mock.Mock() From 0dabc2f95aabe5281695ba95ad99d2491b2f0f55 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Sat, 27 Jun 2026 21:19:09 +0200 Subject: [PATCH 20/26] fix: don't pitch shift twice --- celune/backends/vc/seedvc.py | 4 +++- celune/config.py | 4 ++-- tests/test_backends_and_extensions.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/celune/backends/vc/seedvc.py b/celune/backends/vc/seedvc.py index c9ac8f6..ae0ba06 100644 --- a/celune/backends/vc/seedvc.py +++ b/celune/backends/vc/seedvc.py @@ -233,7 +233,9 @@ def convert(self, request: VoiceConversionRequest) -> AudioOutput: inference_cfg_rate=self.inference_cfg_rate, f0_condition=f0_condition, auto_f0_adjust=self.auto_f0_adjust, - pitch_shift=self.pitch_shift, + pitch_shift=request.pitch_shift + if request.pitch_shift is not None + else 0, stream_output=False, ) ) diff --git a/celune/config.py b/celune/config.py index d95d325..c337e0b 100644 --- a/celune/config.py +++ b/celune/config.py @@ -3,7 +3,7 @@ import os from copy import deepcopy -from typing import Optional, Union +from typing import Optional, Union, Literal from collections.abc import Mapping import sounddevice as sd @@ -14,7 +14,7 @@ ENABLED_ENV_VALUES = {"1", "true", "on", "yes", "enabled"} AudioDeviceConfig = Optional[Union[int, str]] -AudioDeviceDirection = str +AudioDeviceDirection = Literal["input", "output"] def env_bool(name: str, fallback: bool = False) -> bool: diff --git a/tests/test_backends_and_extensions.py b/tests/test_backends_and_extensions.py index e5d9802..332f237 100644 --- a/tests/test_backends_and_extensions.py +++ b/tests/test_backends_and_extensions.py @@ -320,7 +320,7 @@ def result_generator() -> Generator[ ) ) - self.assertEqual(captured["pitch_shift"], -6) + self.assertEqual(captured["pitch_shift"], 9) def test_seedvc_backend_prefers_request_f0_condition_over_backend_default( self, From 71a415efb41462305d0495cbfa00889dd3591413 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Sun, 28 Jun 2026 13:31:50 +0200 Subject: [PATCH 21/26] refactor: refactor UI/core to be startable without models in test mode --- celune/api.py | 9 +- celune/backends/tts/base.py | 1 + celune/backends/vc/base.py | 1 + celune/backends/vc/seedvc.py | 4 +- celune/celune.py | 12 +- celune/config.py | 15 ++- celune/entrypoint.py | 21 +++- celune/lang/en.json | 5 +- celune/pipeline.py | 6 +- celune/runtime.py | 17 ++- celune/ui/app.py | 54 ++++++++- tests/support.py | 1 + tests/test_backends_and_extensions.py | 17 +-- tests/test_main_doctor.py | 14 +++ tests/test_runtime_and_ui_commands.py | 168 +++++++++++++++++++++++++- 15 files changed, 313 insertions(+), 32 deletions(-) diff --git a/celune/api.py b/celune/api.py index 7ce2e88..f660efe 100644 --- a/celune/api.py +++ b/celune/api.py @@ -504,7 +504,14 @@ def _authenticated(request: Request) -> bool: def is_browser_ui_request(request: Request) -> bool: - """Return whether the request targets the mounted browser UI.""" + """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/") diff --git a/celune/backends/tts/base.py b/celune/backends/tts/base.py index 1889efd..f496b47 100644 --- a/celune/backends/tts/base.py +++ b/celune/backends/tts/base.py @@ -250,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 diff --git a/celune/backends/vc/base.py b/celune/backends/vc/base.py index ab0efb5..ff124a9 100644 --- a/celune/backends/vc/base.py +++ b/celune/backends/vc/base.py @@ -13,6 +13,7 @@ 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 diff --git a/celune/backends/vc/seedvc.py b/celune/backends/vc/seedvc.py index c9ac8f6..ae0ba06 100644 --- a/celune/backends/vc/seedvc.py +++ b/celune/backends/vc/seedvc.py @@ -233,7 +233,9 @@ def convert(self, request: VoiceConversionRequest) -> AudioOutput: inference_cfg_rate=self.inference_cfg_rate, f0_condition=f0_condition, auto_f0_adjust=self.auto_f0_adjust, - pitch_shift=self.pitch_shift, + pitch_shift=request.pitch_shift + if request.pitch_shift is not None + else 0, stream_output=False, ) ) diff --git a/celune/celune.py b/celune/celune.py index fa77ab3..d79c69e 100644 --- a/celune/celune.py +++ b/celune/celune.py @@ -3,6 +3,7 @@ import os import gc +import sys import time import queue import shutil @@ -2151,7 +2152,12 @@ def load(self) -> bool: Returns: bool: ``True`` when initialization completed successfully, otherwise ``False``. """ - log_runtime_banner(self.log, self._active_runtime_backend_name()) + 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 + self._cleanup_residual_temp_data(app_data_dir() / "temp") if not self.load_available_voices(): self.cur_state = "error" @@ -2794,8 +2800,8 @@ def submit_audio( 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. + 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. diff --git a/celune/config.py b/celune/config.py index d95d325..13c22a9 100644 --- a/celune/config.py +++ b/celune/config.py @@ -3,7 +3,7 @@ import os from copy import deepcopy -from typing import Optional, Union +from typing import Optional, Union, Literal from collections.abc import Mapping import sounddevice as sd @@ -14,7 +14,7 @@ ENABLED_ENV_VALUES = {"1", "true", "on", "yes", "enabled"} AudioDeviceConfig = Optional[Union[int, str]] -AudioDeviceDirection = str +AudioDeviceDirection = Literal["input", "output"] def env_bool(name: str, fallback: bool = False) -> bool: @@ -103,7 +103,16 @@ def resolve_audio_device( key: str, direction: AudioDeviceDirection, ) -> AudioDeviceConfig: - """Resolve one configured audio device into an exact PortAudio selector.""" + """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 diff --git a/celune/entrypoint.py b/celune/entrypoint.py index 6a20072..229aefd 100644 --- a/celune/entrypoint.py +++ b/celune/entrypoint.py @@ -46,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. @@ -959,17 +965,25 @@ def handle_config(command_args: list[str], prog_name: str) -> None: 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() + 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) + try: if runtime.supports_ansi(): sys.stdout.write(f"\x1b]2;{string('osc.starting', app_name=APP_NAME)}\x07") @@ -1258,7 +1272,7 @@ def main(argv: Optional[list[str]] = None) -> None: start() elif args[0] in {"start", "run"}: if len(args) > 1: - if args[1] not in {"--verbose", "-v"}: + if args[1] not in {"--verbose", "-v", "--test", "-t"}: print(string("cli.invalid_argument")) print() print( @@ -1267,7 +1281,8 @@ def main(argv: Optional[list[str]] = None) -> None: 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": diff --git a/celune/lang/en.json b/celune/lang/en.json index 7032c2d..0f98074 100644 --- a/celune/lang/en.json +++ b/celune/lang/en.json @@ -80,6 +80,8 @@ "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})", @@ -140,7 +142,7 @@ "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\tStart {app_name}.", + "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.", @@ -404,6 +406,7 @@ "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}.", diff --git a/celune/pipeline.py b/celune/pipeline.py index 7dc162a..24f527d 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -1582,7 +1582,7 @@ def queue_speech( engine.model_ready.wait() - if not engine.loaded: + 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) @@ -1632,7 +1632,7 @@ def queue_speech( return False try: - if not engine.loaded: + 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) @@ -2055,7 +2055,7 @@ def generation_worker(engine: Celune) -> None: try: engine.model_ready.wait() - if not engine.loaded: + 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: 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/ui/app.py b/celune/ui/app.py index 8c9d02e..b76d191 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -563,6 +563,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: @@ -725,6 +748,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 { @@ -763,7 +787,8 @@ def on_mount(self) -> None: self.refresh_vc_controls() self._refresh_theme_text() self._refresh_logs() - self._enable_runtime_log_capture() + if not self.celune.backend.is_fake or "pytest" in sys.modules: + self._enable_runtime_log_capture() ui_resources.prime_usage() self.set_interval(FOOTER_ROTATE_SECONDS, self.advance_resources) self._status_marquee_timer = self.set_interval( @@ -1093,6 +1118,14 @@ 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(string("ui.app_could_not_start", app_name=APP_NAME)) @@ -1355,7 +1388,9 @@ 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 if self._is_voice_conversion_mode(): @@ -1959,6 +1994,15 @@ 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(string("status.waking_up")) @@ -2014,7 +2058,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 @@ -2238,7 +2282,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.change_voice_lock_state(locked=True) return - if not self.celune_ready: + 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 @@ -2270,7 +2314,7 @@ 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 diff --git a/tests/support.py b/tests/support.py index f6eef27..aabb372 100644 --- a/tests/support.py +++ b/tests/support.py @@ -42,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 diff --git a/tests/test_backends_and_extensions.py b/tests/test_backends_and_extensions.py index e5d9802..2b65e4b 100644 --- a/tests/test_backends_and_extensions.py +++ b/tests/test_backends_and_extensions.py @@ -230,10 +230,11 @@ def convert_voice(**kwargs): """Return a generator-style conversion result for backend tests. Args: - kwargs: Value for `kwargs`. + kwargs: Wrapper arguments forwarded from the backend under test. Returns: - Result of this function. + 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() @@ -290,10 +291,11 @@ def convert_voice(**kwargs): """Return a generator-style conversion result for backend tests. Args: - kwargs: Value for `kwargs`. + kwargs: Wrapper arguments forwarded from the backend under test. Returns: - Result of this function. + Generator[None, None, npt.NDArray[np.float32]]: One generator whose return value carries the + converted waveform. """ captured.update(kwargs) @@ -320,7 +322,7 @@ def result_generator() -> Generator[ ) ) - self.assertEqual(captured["pitch_shift"], -6) + self.assertEqual(captured["pitch_shift"], 9) def test_seedvc_backend_prefers_request_f0_condition_over_backend_default( self, @@ -340,10 +342,11 @@ def convert_voice(**kwargs): """Return a generator-style conversion result for backend tests. Args: - kwargs: Value for `kwargs`. + kwargs: Wrapper arguments forwarded from the backend under test. Returns: - Result of this function. + Generator[None, None, npt.NDArray[np.float32]]: One generator whose return value carries the + converted waveform. """ captured.update(kwargs) 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_runtime_and_ui_commands.py b/tests/test_runtime_and_ui_commands.py index d59774f..5a3b0bf 100644 --- a/tests/test_runtime_and_ui_commands.py +++ b/tests/test_runtime_and_ui_commands.py @@ -592,7 +592,14 @@ def test_textual_ui_mount_enables_stdio_redirects_before_runtime_load(self) -> N "#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 @@ -628,6 +635,98 @@ def test_textual_ui_mount_enables_stdio_redirects_before_runtime_load(self) -> N 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.""" ui = CeluneUI() @@ -784,6 +883,7 @@ def test_load_tts_writes_terminal_title_to_original_stdout(self) -> None: use_normalization=False, dev=False, glow=SimpleNamespace(fatal=lambda: None), + try_play_signal=mock.Mock(return_value=True), ), ) terminal = mock.Mock() @@ -856,6 +956,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() From 35e31fb43bd6e67889c19d946907692df9026f19 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Sun, 28 Jun 2026 16:50:33 +0200 Subject: [PATCH 22/26] feat: make VC buttons auto-hide when not in use --- celune/ui/app.py | 14 +++-- tests/test_runtime_and_ui_commands.py | 86 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/celune/ui/app.py b/celune/ui/app.py index b76d191..cc7e30b 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -1393,11 +1393,7 @@ def update() -> None: else self._normal_input_placeholder() ) self.style_button.disabled = locked - if self._is_voice_conversion_mode(): - if self.vc_mode_button is not None: - self.vc_mode_button.disabled = locked - if self.vc_pitch_button is not None: - self.vc_pitch_button.disabled = locked + self.refresh_vc_controls() self.update_resources() self._run_on_ui_thread(update) @@ -1482,6 +1478,13 @@ 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 ( @@ -1492,6 +1495,7 @@ def refresh_vc_controls(self) -> 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)) diff --git a/tests/test_runtime_and_ui_commands.py b/tests/test_runtime_and_ui_commands.py index 5a3b0bf..3e7dd2a 100644 --- a/tests/test_runtime_and_ui_commands.py +++ b/tests/test_runtime_and_ui_commands.py @@ -1432,6 +1432,36 @@ def test_textual_input_lock_update_with_persona_on_ui_thread(self) -> None: 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() @@ -1488,6 +1518,62 @@ def test_placeholder_uses_voice_changer_text_when_vc_backend_is_selected( 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 external Python logger warnings are routed into the UI logs.""" ui = CeluneUI() From 4ee727156e7e38c86c304790bb040f915c82e167 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Sun, 28 Jun 2026 19:18:56 +0200 Subject: [PATCH 23/26] chore: fixed some more edge cases --- celune/entrypoint.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/celune/entrypoint.py b/celune/entrypoint.py index 229aefd..e265b53 100644 --- a/celune/entrypoint.py +++ b/celune/entrypoint.py @@ -977,14 +977,14 @@ def start(verbose: bool = False, testing: bool = False) -> None: Exception: Re-raised after printing a traceback in developer mode. """ runtime = _load_runtime() - 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) 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() @@ -1271,15 +1271,13 @@ def main(argv: Optional[list[str]] = None) -> None: if not args: start() elif args[0] in {"start", "run"}: - if len(args) > 1: - if args[1] not in {"--verbose", "-v", "--test", "-t"}: - print(string("cli.invalid_argument")) - print() - 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) + 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(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:]) testing = any(arg in {"--test", "-t"} for arg in args[1:]) start(verbose=verbose, testing=testing) From 078f66709eb762c31efabd642947d461355f62cf Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Mon, 29 Jun 2026 13:07:46 +0200 Subject: [PATCH 24/26] fix: pipeline optimizations --- celune/config.py | 6 +- celune/pipeline.py | 85 ++++++---- celune/ui/app.py | 230 ++++++++++++++++++++++---- tests/test_pipeline.py | 27 ++- tests/test_runtime_and_ui_commands.py | 92 ++++++----- 5 files changed, 333 insertions(+), 107 deletions(-) diff --git a/celune/config.py b/celune/config.py index 13c22a9..b205f81 100644 --- a/celune/config.py +++ b/celune/config.py @@ -15,6 +15,7 @@ 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: @@ -121,7 +122,7 @@ def resolve_audio_device_with_info( config: Optional[Mapping[str, JSONSerializable]], key: str, direction: AudioDeviceDirection, -) -> tuple[AudioDeviceConfig, Optional[Mapping[str, object]]]: +) -> tuple[AudioDeviceConfig, Optional[Mapping[str, AudioDeviceInfoValue]]]: """Resolve one configured audio device into an exact PortAudio selector. Args: @@ -130,7 +131,8 @@ def resolve_audio_device_with_info( direction: Whether the device must support ``"input"`` or ``"output"``. Returns: - tuple[AudioDeviceConfig, Optional[Mapping[str, object]]]: The resolved selector and optional direct device info. + tuple[AudioDeviceConfig, Optional[Mapping[str, AudioDeviceInfoValue]]]: The resolved selector and optional + direct device info. Raises: ValueError: The configured device name matches multiple devices. diff --git a/celune/pipeline.py b/celune/pipeline.py index 24f527d..84ccd27 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -733,6 +733,41 @@ def _queue_playback_done( ) +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, "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" @@ -2267,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() @@ -2345,21 +2369,14 @@ def generation_worker(engine: Celune) -> None: ) 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(string("pipeline.generation_done")) @@ -2496,13 +2513,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 diff --git a/celune/ui/app.py b/celune/ui/app.py index cc7e30b..101d667 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -17,7 +17,7 @@ from pathlib import Path from collections.abc import Iterator from dataclasses import dataclass, field -from typing import Optional, Callable, Protocol, Union, TextIO, cast +from typing import Optional, Callable, Union, TextIO, cast import numpy as np import numpy.typing as npt @@ -64,7 +64,8 @@ _VC_PITCH_SHIFT_MIN = -12 _VC_PITCH_SHIFT_MAX = 12 -_VC_LIVE_STREAM_CHUNK_SECONDS = 1.0 +_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 @@ -83,9 +84,19 @@ "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: object, default: int) -> int: +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 @@ -185,7 +196,7 @@ class CeluneUIInteractionState: 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]]] + 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 @@ -475,16 +486,7 @@ def _clear_border_pulses(self) -> None: def _refresh_theme_text(self) -> None: """Refresh widgets after a runtime theme change.""" - class _RefreshableWidget(Protocol): - def refresh(self, *args, **kwargs) -> object: - """Refresh one widget in place. - - Args: - args: Positional refresh arguments accepted by the widget. - kwargs: Keyword refresh arguments accepted by the widget. - """ - - def repaint(widget: _RefreshableWidget) -> None: + def repaint(widget: Widget) -> None: refresh = getattr(widget, "refresh", None) if refresh is None: return @@ -1599,6 +1601,49 @@ def _flush_vc_recording_buffer_locked(self) -> Optional[npt.NDArray[np.float32]] 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 _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 = np.asarray(previous_tail[-overlap_frames:], dtype=np.float32) + current = np.asarray(current_head[:overlap_frames], dtype=np.float32) + fade = np.linspace(0.0, 1.0, overlap_frames, dtype=np.float32)[:, 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 @@ -1621,7 +1666,7 @@ def _stop_vc_recording_stream( int, str, Optional[ - queue_module.Queue[Optional[tuple[npt.NDArray[np.float32], int, str]]] + queue_module.Queue[Optional[tuple[npt.NDArray[np.float32], int, str, bool]]] ], int, ]: @@ -1685,7 +1730,7 @@ def _stop_vc_recording_for_feedback(self) -> None: _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)) + submission_queue.put((buffered_audio, sample_rate, label, True)) if submission_queue is not None: submission_queue.put(None) self._shutdown_vc_stream(stream) @@ -1726,10 +1771,10 @@ def _start_vc_recording(self) -> bool: try: device_info = ( - cast(dict[str, object], dict(direct_device_info)) + cast(dict[str, _AudioDeviceScalar], dict(direct_device_info)) if direct_device_info is not None else cast( - dict[str, object], + dict[str, _AudioDeviceScalar], sd.query_devices(device=input_device, kind="input"), ) ) @@ -1758,31 +1803,156 @@ def _start_vc_recording(self) -> bool: 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]] + 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 = item + audio, queued_sample_rate, queued_label, is_final_chunk = item try: if self.celune is None: continue - if not self.celune.submit_audio( + converted = self.celune.convert_audio( audio, queued_sample_rate, label=queued_label, - log_playback=False, - reset_ready_announcement=False, - ): + ) + 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 @@ -1800,8 +1970,8 @@ def submit_live_audio() -> None: def callback( indata: npt.NDArray[np.float32], frames: int, - time_info: object, - status: object, + time_info: Optional[tuple[float, float, float]], + status: Optional[sd.CallbackFlags], ) -> None: discard(frames) discard(time_info) @@ -1831,10 +2001,12 @@ def callback( self._vc_recording_buffered_frames >= stream_chunk_frames and self._vc_recording_submission_queue is not None ): - buffered_audio = self._flush_vc_recording_buffer_locked() + 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) + (buffered_audio, sample_rate, label, False) ) if should_stop_for_feedback: @@ -1895,7 +2067,7 @@ def toggle_vc_recording(self) -> bool: 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)) + submission_queue.put((buffered_audio, sample_rate, label, True)) if submission_queue is not None: submission_queue.put(None) self._shutdown_vc_stream(stream) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 3199e83..b8ef317 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -749,8 +749,9 @@ 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) @@ -2287,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) @@ -2349,9 +2350,29 @@ 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: diff --git a/tests/test_runtime_and_ui_commands.py b/tests/test_runtime_and_ui_commands.py index 3e7dd2a..c467778 100644 --- a/tests/test_runtime_and_ui_commands.py +++ b/tests/test_runtime_and_ui_commands.py @@ -6,12 +6,13 @@ import logging import tempfile import warnings -from typing import Callable, Optional, 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 @@ -22,6 +23,7 @@ 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 @@ -1143,7 +1145,16 @@ def test_ctrl_r_toggles_tui_vc_recording(self) -> None: SimpleNamespace( input_mode="voice_conversion", vc_backend=SimpleNamespace(), - submit_audio=mock.Mock(return_value=True), + 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)"}, @@ -1151,12 +1162,10 @@ def test_ctrl_r_toggles_tui_vc_recording(self) -> None: ) ui.safe_log = mock.Mock() ui.update_resources = mock.Mock() - captured_callback: Optional[ - Callable[[np.ndarray, int, object, object], None] - ] = None + captured_callback: Optional[ui_app._VCAudioCallback] = None def invoke_captured_callback( - callback: Callable[[np.ndarray, int, object, object], None], + callback: ui_app._VCAudioCallback, audio: np.ndarray, ) -> None: callback( @@ -1202,18 +1211,18 @@ def __init__(self, **kwargs) -> None: else: invoke_captured_callback( cast( - Callable[[np.ndarray, int, object, object], None], + ui_app._VCAudioCallback, captured_callback, ), - np.ones((48000, 2), dtype=np.float32), + np.ones((20000, 2), dtype=np.float32), ) for _ in range(50): - if cast(mock.Mock, ui.celune.submit_audio).call_count >= 1: + if cast(mock.Mock, ui.celune.convert_audio).call_count >= 1: break time.sleep(0.01) invoke_captured_callback( cast( - Callable[[np.ndarray, int, object, object], None], + ui_app._VCAudioCallback, captured_callback, ), np.ones((8, 2), dtype=np.float32), @@ -1235,32 +1244,32 @@ def __init__(self, **kwargs) -> None: "Stereo Mix (Realtek)", ) for _ in range(50): - if cast(mock.Mock, ui.celune.submit_audio).call_count >= 2: + if cast(mock.Mock, ui.celune.convert_audio).call_count >= 2: break time.sleep(0.01) - self.assertGreaterEqual(cast(mock.Mock, ui.celune.submit_audio).call_count, 2) - submit_calls = cast(mock.Mock, ui.celune.submit_audio).call_args_list - first_submit_args = submit_calls[0].args - final_submit_args = submit_calls[-1].args - self.assertEqual(first_submit_args[1], 48000) - self.assertEqual(final_submit_args[1], 48000) - self.assertEqual( - submit_calls[-1].kwargs["label"], - "Stereo Mix", + 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(submit_calls[0].kwargs["log_playback"], False) - self.assertEqual(submit_calls[-1].kwargs["log_playback"], False) + self.assertEqual(first_convert_args[1], 48000) + self.assertEqual(final_convert_args[1], 48000) self.assertEqual( - submit_calls[0].kwargs["reset_ready_announcement"], - False, - ) - self.assertEqual( - submit_calls[-1].kwargs["reset_ready_announcement"], - False, + convert_calls[-1].kwargs["label"], + "Stereo Mix", ) - self.assertGreaterEqual(len(first_submit_args[0]), 36000) - self.assertEqual(len(final_submit_args[0]), 8) + 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.""" @@ -1271,16 +1280,23 @@ def test_vc_recording_feedback_spike_stops_live_stream(self) -> None: SimpleNamespace( input_mode="voice_conversion", vc_backend=SimpleNamespace(), - submit_audio=mock.Mock(return_value=True), + 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[ - Callable[[np.ndarray, int, object, object], None] - ] = None + captured_callback: Optional[ui_app._VCAudioCallback] = None class FakeInputStream: """Tiny input-stream fake for VC feedback-stop tests.""" @@ -1313,8 +1329,8 @@ def __init__(self, **kwargs) -> None: def missing_callback( _audio: np.ndarray, _frames: int, - _time_info: object, - _status: object, + _time_info: Optional[tuple[float, float, float]], + _status: Optional[sd.CallbackFlags], ) -> None: raise AssertionError("recording callback was not registered") @@ -1490,7 +1506,7 @@ 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() @@ -1509,7 +1525,7 @@ def test_placeholder_uses_voice_changer_text_when_vc_backend_is_selected( SimpleNamespace( config={}, vc_backend=SimpleNamespace(), - vision=object(), + vision=SimpleNamespace(), ), ) From f9c7455ea2cbf8cbd4877989ee9bb97c92f0a1b4 Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Mon, 29 Jun 2026 13:41:44 +0200 Subject: [PATCH 25/26] chore: fixed both actionable comments --- celune/pipeline.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/celune/pipeline.py b/celune/pipeline.py index 84ccd27..5b72d27 100644 --- a/celune/pipeline.py +++ b/celune/pipeline.py @@ -760,7 +760,7 @@ def _flush_buffered_speech_chunks( buffer.clear() if not pushed_audio: - _set_playback_source_status(engine, source_id, "Speaking") + _set_playback_source_status(engine, source_id, string("status.speaking")) engine.cur_state = "speaking" engine.queue_avail_callback() return True @@ -2403,6 +2403,12 @@ def generation_worker(engine: Celune) -> None: 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( From 9ec2ec82a584da575765cb442d41a1faca0bb26d Mon Sep 17 00:00:00 2001 From: Piotr Zalewski Date: Mon, 29 Jun 2026 17:17:28 +0200 Subject: [PATCH 26/26] fix: move SeedVC checkpoints into shared app cache --- celune/backends/vc/seedvc.py | 13 ++++------ celune/ui/app.py | 36 ++++++++++++++++++++++++--- tests/test_backends_and_extensions.py | 10 ++++---- tests/test_runtime_and_ui_commands.py | 27 ++++++++++++++++++++ 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/celune/backends/vc/seedvc.py b/celune/backends/vc/seedvc.py index ae0ba06..edad7b2 100644 --- a/celune/backends/vc/seedvc.py +++ b/celune/backends/vc/seedvc.py @@ -18,7 +18,7 @@ from .base import CeluneVCBackend from ...dataclasses.pipeline import AudioOutput, VoiceConversionRequest from ...i18n import string -from ...paths import app_data_dir +from ...paths import huggingface_hub_cache_dir __all__ = ["CeluneSeedVCBackend"] @@ -85,12 +85,9 @@ def __init__( self._wrapper_lock = threading.Lock() @staticmethod - def _seedvc_checkpoints_dir(create: bool = False) -> Path: - """Return the persistent Celune cache directory used for Seed-VC assets.""" - path = app_data_dir(create=create) / "checkpoints" - if create: - path.mkdir(parents=True, exist_ok=True) - return path + 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( @@ -99,7 +96,7 @@ def _configure_seedvc_downloads( wrapper_module: ModuleType, ) -> None: """Redirect Seed-VC's hardcoded checkpoint downloads into Celune's cache.""" - cache_dir = cls._seedvc_checkpoints_dir(create=True) + 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( diff --git a/celune/ui/app.py b/celune/ui/app.py index 101d667..9a249f0 100644 --- a/celune/ui/app.py +++ b/celune/ui/app.py @@ -1626,6 +1626,26 @@ def _flush_vc_recording_chunk_locked( 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], @@ -1636,9 +1656,19 @@ def _crossfade_vc_overlap( if overlap_frames <= 0: return np.zeros((0, 2), dtype=np.float32) - previous = np.asarray(previous_tail[-overlap_frames:], dtype=np.float32) - current = np.asarray(current_head[:overlap_frames], dtype=np.float32) - fade = np.linspace(0.0, 1.0, overlap_frames, dtype=np.float32)[:, None] + 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, diff --git a/tests/test_backends_and_extensions.py b/tests/test_backends_and_extensions.py index 2b65e4b..f8f9a61 100644 --- a/tests/test_backends_and_extensions.py +++ b/tests/test_backends_and_extensions.py @@ -376,10 +376,10 @@ def result_generator() -> Generator[ self.assertEqual(captured["f0_condition"], True) self.assertEqual(output.sample_rate, 44100) - def test_seedvc_backend_redirects_package_checkpoint_downloads_into_app_data( + def test_seedvc_backend_redirects_package_checkpoint_downloads_into_hf_cache( self, ) -> None: - """Verify Celune overrides Seed-VC's repo-local checkpoint cache path.""" + """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]] = [] @@ -409,12 +409,12 @@ def import_module(name: str) -> ModuleType: return importlib.import_module(name) with tempfile.TemporaryDirectory() as temp_dir: - expected_cache_dir = Path(temp_dir) / "checkpoints" + expected_cache_dir = Path(temp_dir) / "huggingface" / "hub" with ( mock.patch( - "celune.backends.vc.seedvc.app_data_dir", - return_value=Path(temp_dir), + "celune.backends.vc.seedvc.huggingface_hub_cache_dir", + return_value=expected_cache_dir, ), mock.patch( "celune.backends.vc.seedvc.importlib.import_module", diff --git a/tests/test_runtime_and_ui_commands.py b/tests/test_runtime_and_ui_commands.py index c467778..92d9288 100644 --- a/tests/test_runtime_and_ui_commands.py +++ b/tests/test_runtime_and_ui_commands.py @@ -571,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()