From 3363f69c2890bc42334ff4111f7ddfc090b222f9 Mon Sep 17 00:00:00 2001 From: THEINAOG <216241348+IAnMove@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:21:03 +0200 Subject: [PATCH 1/3] Add reusable custom character voices from audio imports and microphone recordings --- README.md | 4 + app/_launch_runtime.py | 1 + app/services/character_speech_definition.py | 52 +++++- app/services/media_paths.py | 38 ++++- tests/test_custom_character_voice.py | 160 ++++++++++++++++++ .../characters/CharacterDefinitionEditor.tsx | 19 ++- .../characters/CharacterKitSummary.tsx | 2 +- .../characters/CharacterVoiceAudition.tsx | 48 ++++++ .../characters/CharacterVoiceFields.tsx | 48 +++++- .../characters/CustomCharacterVoiceFields.tsx | 41 +++++ .../characters/characterVoiceReference.ts | 17 ++ .../characters/useVoiceReferenceCapture.ts | 43 +++++ ui/src/features/scene3d/speech/microphone.ts | 4 +- ui/src/features/series/SeriesVoiceFields.tsx | 2 +- ui/src/i18n/locales/en/scene3dEditor.json | 75 +++++++- ui/src/i18n/locales/es/scene3dEditor.json | 75 +++++++- ui/src/lib/characterKit.ts | 5 +- ui/src/lib/characterVoice.ts | 54 +++++- ui/src/lib/characterVoiceCatalog.ts | 15 ++ ui/src/lib/sceneSpeech.ts | 19 ++- ui/tests/characterVoiceAudition.test.tsx | 68 ++++++++ ui/tests/customCharacterVoice.test.ts | 96 +++++++++++ ui/tests/customCharacterVoiceFields.test.tsx | 131 ++++++++++++++ 23 files changed, 984 insertions(+), 33 deletions(-) create mode 100644 tests/test_custom_character_voice.py create mode 100644 ui/src/features/characters/CharacterVoiceAudition.tsx create mode 100644 ui/src/features/characters/CustomCharacterVoiceFields.tsx create mode 100644 ui/src/features/characters/characterVoiceReference.ts create mode 100644 ui/src/features/characters/useVoiceReferenceCapture.ts create mode 100644 ui/src/lib/characterVoiceCatalog.ts create mode 100644 ui/tests/characterVoiceAudition.test.tsx create mode 100644 ui/tests/customCharacterVoice.test.ts create mode 100644 ui/tests/customCharacterVoiceFields.test.tsx diff --git a/README.md b/README.md index 19e452c29..6a306620e 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,10 @@ You can also enable production methods directly in **Series Lab → Shots**. For Location image prompts describe empty environments. Series Lab separates the physical setting and rendering style from character design and narrative occupants before generating. Use **Prepare environment prompt** in the location card to review the exact prompt first. +Character Creator identifies each Qwen3 preset by its original language/profile and timbre. The nine presets can speak several languages, but none is natively Spanish. Use **Generate voice sample** with a Spanish or English sentence to hear the selected voice before saving or preparing mouths. Changing the voice cancels only that audition and stops the previous sample. + +Choose **Add your own voice: import or record** to use **Qwen3 Base** with a clean 3–30 second recording (up to 20 MB). Import an audio file or record with the microphone, name the voice, enter the exact recording transcript, and choose the language for new dialogue. Audition it, then **Save everything** on the character. Saved custom voices appear in the voice selector for other characters; new or regenerated native 2D/3D dialogue uses the stored recording and transcript. Existing takes remain available. Recording requires a browser with microphone support on HTTPS or localhost; importing also works over LAN HTTP. Audio samples are stored as persistent local uploads, and character metadata stores public references rather than machine-specific paths. No new model or recording is generated merely by selecting or saving a voice. + Each **Canon → Characters** card also shows voice, 2D lip-sync and 3D lip-sync readiness. **Configure in Character Creator** opens a dedicated view for that exact character, carries over its reference image, and links the saved configuration by ID. The Series card keeps its library selector. **Save everything and return to Series Lab** saves voice, mouth images and placement together, then returns to the source character. A failed save retains the draft and keeps the editor open; a fully saved session can yield to the next character even after switching tabs. A voice can be saved without a 3D model. Save the character before opening its 2D mouth workshop or 3D face calibration. The mouth workshop has a rectangle whose width and height can be adjusted independently, a visible mouth-pack catalog with previews, explicit AI generation buttons, and a one-click prerecorded English voice sample for previewing mouth movement without generating speech. **Apply placement to all mouths** copies the current position, scale and rotation to all nine mouth shapes. **Try with their voice** previews the full isolated recording with the same phonetic analyzer as native shots, including pauses and resting-mouth closure; the separate quick text preview is approximate. Eyes and blinking are optional: keep the original drawing unless you want to add overlays. Review the cleaned base and mouth variants, then save the speech character. Dialogue shots open Character Creator directly; the advanced voice table links to the character card. AI video with native audio continues to use its generator's voice; the reusable TTS preset is used in the speech editor. ### Finish without regenerating diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py index 4c0bb3ae9..bb0eaf324 100644 --- a/app/_launch_runtime.py +++ b/app/_launch_runtime.py @@ -1088,6 +1088,7 @@ def _resolve_request_media_path( uploads_root=os.path.join(os.getcwd(), "uploads"), workspace_root=_workspace_dir(workspace), kinds=kinds, + workspace_name=workspace or _get_active_workspace(), ) except MediaPathNotAllowed: raise HTTPException(status_code=400, detail="Media path is not allowed") from None diff --git a/app/services/character_speech_definition.py b/app/services/character_speech_definition.py index dc58b0738..262dec2b4 100644 --- a/app/services/character_speech_definition.py +++ b/app/services/character_speech_definition.py @@ -2,7 +2,41 @@ import json import math import re -from urllib.parse import urlsplit, parse_qsl +from urllib.parse import urlsplit, parse_qsl, unquote + +CHARACTER_VOICE_LANGUAGES = {"auto", "chinese", "english", "japanese", "korean", "german", "french", "russian", "portuguese", "spanish", "italian"} + + +def _voice_reference_query(kind, raw): + if kind == "uploads": + if raw is not None: + raise ValueError("Upload references cannot contain query parameters.") + return + query = parse_qsl(raw or "", keep_blank_values=True) + if (len(query) != 1 or query[0][0] != "workspace" + or not re.fullmatch(r"[A-Za-z0-9_. -]{1,120}", query[0][1]) or query[0][1] in {".", ".."}): + raise ValueError("The reference recording needs its source workspace.") + + +def normalize_character_voice_reference(value): + """Persistent same-origin audio only, with an explicit source workspace.""" + if not isinstance(value, str) or not value or len(value) > 1200 or re.search(r"\s|[\\#]", value): + raise ValueError("Import a local reference recording first.") + match = re.fullmatch(r"/api/v1/(uploads|file)/([^?]+)(?:\?(.+))?", value) + if not match: + raise ValueError("Use a persistent local reference recording.") + if re.search(r"%(?![0-9a-fA-F]{2})", value): + raise ValueError("Invalid reference recording URL.") + try: + path = unquote(match[2], errors="strict") + unquote(match[3] or "", errors="strict") + except UnicodeDecodeError as error: + raise ValueError("Invalid reference recording URL.") from error + if (re.search(r"[\x00-\x1f\x7f\\%?#]", path) or any(not part or part.startswith(".") for part in path.split("/")) + or not re.search(r"\.(wav|mp3|m4a|aac|flac|ogg|opus)$", path, re.I)): + raise ValueError("Choose a local audio recording.") + _voice_reference_query(match[1], match[3]) + return value def _asset_fields(value): @@ -93,7 +127,23 @@ def normalize_speech3d(value): return result +def _voice_text(value, limit): + return isinstance(value, str) and bool(value.strip()) and len(value) <= limit + + +def _normalize_reference_voice(value): + if (set(value) != {"provider", "model", "voiceId", "name", "referenceAudio", "transcript", "language"} + or value.get("provider") != "local" or value.get("voiceId") != "reference" + or not _voice_text(value.get("name"), 120) or not _voice_text(value.get("transcript"), 4000) + or not isinstance(value.get("language"), str) or value["language"] not in CHARACTER_VOICE_LANGUAGES): + raise ValueError("Add a named local reference recording, its transcript and a supported language.") + return {**value, "name": value["name"].strip(), "transcript": value["transcript"].strip(), + "referenceAudio": normalize_character_voice_reference(value["referenceAudio"])} + + def normalize_character_voice(value): + if isinstance(value, dict) and value.get("model") == "qwen3_tts_base": + return _normalize_reference_voice(value) if not isinstance(value, dict) or set(value) - {"provider", "model", "voiceId", "instructions"}: raise ValueError("Store voice preferences only, never credentials.") if value.get("provider") != "local" or value.get("model") != "qwen3_tts_customvoice": diff --git a/app/services/media_paths.py b/app/services/media_paths.py index 277fb8a3a..4c93ef46d 100644 --- a/app/services/media_paths.py +++ b/app/services/media_paths.py @@ -3,7 +3,9 @@ from __future__ import annotations import os +import re from collections.abc import Iterable +from urllib.parse import parse_qsl, unquote, urlsplit class MediaPathNotAllowed(ValueError): @@ -31,12 +33,40 @@ def _is_contained(path: str, root: str) -> bool: return False +def _canonical_media_path(value: str, uploads_root: str, workspace_root: str, workspace_name: str | None): + """Resolve the declared API root, without same-name fallback across roots.""" + if any(ord(char) <= 32 or char in "\\#" for char in value) or re.search(r"%(?![0-9a-fA-F]{2})", value): + raise MediaPathNotAllowed("Invalid canonical media reference") + parsed = urlsplit(value) + upload_prefix, file_prefix = "/api/v1/uploads/", "/api/v1/file/" + if parsed.path.startswith(upload_prefix): + if parsed.query: + raise MediaPathNotAllowed("Upload references cannot contain query parameters") + root, relative = uploads_root, parsed.path[len(upload_prefix):] + elif parsed.path.startswith(file_prefix): + query = parse_qsl(parsed.query, keep_blank_values=True) + if len(query) != 1 or query[0] != ("workspace", workspace_name): + raise MediaPathNotAllowed("The reference workspace does not match its declared root") + root, relative = workspace_root, parsed.path[len(file_prefix):] + else: + raise MediaPathNotAllowed("Unsupported canonical media root") + try: + relative = unquote(relative, errors="strict") + except UnicodeDecodeError as error: + raise MediaPathNotAllowed("Invalid canonical media reference") from error + if (any(ord(char) < 32 or ord(char) == 127 or char == "\\" for char in relative) + or any(part in {"", ".", ".."} for part in relative.split("/"))): + raise MediaPathNotAllowed("Media path is not allowed") + return os.path.join(root, relative), root + + def resolve_permitted_media_path( value: str, *, uploads_root: str, workspace_root: str, kinds: Iterable[str] = ("audio", "video"), + workspace_name: str | None = None, ) -> str: """Resolve a media path contained in uploads or one workspace. @@ -57,7 +87,12 @@ def resolve_permitted_media_path( raise MediaPathNotAllowed("Media roots are not available") raw = value.strip() - if os.path.isabs(raw) or os.path.splitdrive(raw)[0]: + if raw.startswith("/api/"): + canonical, declared_root = _canonical_media_path(raw, roots[0], roots[1], workspace_name) + # A canonical file may not follow a symlink into the other allowed root. + roots = (declared_root,) + raw_candidates = (canonical,) + elif os.path.isabs(raw) or os.path.splitdrive(raw)[0]: raw_candidates = (raw,) else: raw_candidates = (raw, *(os.path.join(root, raw) for root in roots)) @@ -165,4 +200,3 @@ def resolve_story_cover_audio( workspace_root=workspace_root, kinds=("audio",), ) - diff --git a/tests/test_custom_character_voice.py b/tests/test_custom_character_voice.py new file mode 100644 index 000000000..2f907c5f4 --- /dev/null +++ b/tests/test_custom_character_voice.py @@ -0,0 +1,160 @@ +import ast +import asyncio +import os +from pathlib import Path +from urllib.parse import quote + +import pytest +from fastapi import HTTPException + +from app.services.character_kit_library import patch_character_kit, read_character_kit_library +from app.services.character_speech_definition import normalize_character_voice +from app.services.media_paths import MediaPathNotAllowed, resolve_permitted_media_path + + +def voice(): + return {"provider": "local", "model": "qwen3_tts_base", "voiceId": "reference", "name": "Narradora", + "referenceAudio": "/api/v1/uploads/audio/recording.wav", "transcript": "Esta es mi voz.", "language": "spanish"} + + +def test_reference_voice_survives_library_reload_and_links_from_another_workspace(tmp_path): + custom = voice() + custom["referenceAudio"] = "/api/v1/file/assets/voice.wav?workspace=original" + kit = {"version": 1, "id": "narrator", "name": "Narrator", "style": "cutout", "poses": {}, + "mouth": {}, "eyes": {}, "anchors": {}, "provenance": [], "voice": custom} + for workspace in ("original", "episode"): + patch_character_kit(str(tmp_path / workspace), "narrator", kit, base_revision=0) + assert read_character_kit_library(str(tmp_path / workspace))["kits"]["narrator"]["voice"] == custom + assert normalize_character_voice({**custom, "name": " Narradora ", "transcript": " Esta es mi voz. "}) == custom + preset = {"provider": "local", "model": "qwen3_tts_customvoice", "voiceId": "ryan", "instructions": "Warm"} + assert normalize_character_voice(preset) == preset + + +@pytest.mark.parametrize("patch", [ + {"name": ""}, {"name": " "}, {"name": "a" * 121}, {"name": 12}, {"transcript": ""}, {"transcript": "\n"}, + {"transcript": "a" * 4001}, {"transcript": None}, {"referenceAudio": ""}, {"language": "martian"}, + {"language": []}, {"voiceId": "ryan"}, {"provider": "remote"}, {"apiKey": "not-a-secret"}, {"instructions": "unsupported"}, +]) +def test_incomplete_or_unsupported_reference_voice_is_rejected(patch): + with pytest.raises(ValueError): + normalize_character_voice({**voice(), **patch}) + + +@pytest.mark.parametrize("key", list(voice())) +def test_every_reference_voice_field_is_required(key): + incomplete = voice() + del incomplete[key] + with pytest.raises(ValueError): + normalize_character_voice(incomplete) + + +@pytest.mark.parametrize("reference", [ + "https://example.com/voice.wav", "//example.com/voice.wav", "blob:voice", "data:audio/wav;base64,AAAA", + "/home/private.wav", "/api/v1/uploads/../voice.wav", "/api/v1/uploads/%2e%2e/voice.wav", + "/api/v1/uploads/%252e%252e/voice.wav", "/api/v1/uploads/audio%5cvoice.wav", "/api/v1/uploads/.private/voice.wav", + "/api/v1/uploads/voice.wav?token=example", "/api/v1/uploads/voice.wav#fragment", "/api/v1/uploads/voice.wav?", + "/api/v1/uploads/voice.webm", + "/api/v1/uploads/voice%00.wav", "/api/v1/uploads/%ZZ.wav", "/api/v1/uploads/voice.json", + "/api/v1/uploads/voice wav.wav", "/api/v1/uploads//voice.wav", "/api/v1/file/voice.wav", + "/api/v1/file/voice.wav?workspace=", "/api/v1/file/voice.wav?workspace=..", + "/api/v1/file/voice.wav?workspace=one&workspace=two", "/api/v1/file/voice.wav?workspace=one&key=example", + "/api/v1/file/voice.wav?workspace=%ZZ", "/api/v1/file/voice.wav?workspace=%FF", +]) +def test_remote_unsafe_or_ambiguous_reference_is_rejected(reference): + with pytest.raises(ValueError): + normalize_character_voice({**voice(), "referenceAudio": reference}) + + +@pytest.fixture +def media_roots(tmp_path): + uploads, workspace = tmp_path / "uploads", tmp_path / "episode" + uploads.mkdir() + workspace.mkdir() + return uploads, workspace + + +def resolve(reference, roots, workspace_name="original"): + return resolve_permitted_media_path(reference, uploads_root=str(roots[0]), workspace_root=str(roots[1]), + workspace_name=workspace_name, kinds=("audio",)) + + +def test_canonical_media_adoption_keeps_the_exact_root_even_with_colliding_names(media_roots): + uploads, workspace = media_roots + for root in media_roots: + (root / "same.wav").write_bytes(b"reference") + assert resolve("/api/v1/uploads/same.wav", media_roots) == str(uploads / "same.wav") + assert resolve("/api/v1/file/same.wav?workspace=original", media_roots) == str(workspace / "same.wav") + (workspace / "same.wav").unlink() + with pytest.raises(FileNotFoundError): + resolve("/api/v1/file/same.wav?workspace=original", media_roots) + (workspace / "nested").mkdir() + (workspace / "nested" / "mi voz.wav").write_bytes(b"reference") + assert resolve("/api/v1/file/nested%2Fmi%20voz.wav?workspace=original", media_roots) == str(workspace / "nested" / "mi voz.wav") + + +@pytest.mark.parametrize("reference", [ + "/api/v1/file/same.wav?workspace=wrong", "/api/v1/file/same.wav", + "/api/v1/file/same.wav?workspace=original&workspace=wrong", "/api/v1/file/same.wav?workspace=original&token=example", + "/api/v1/uploads/same.wav?token=example", "/api/v1/uploads/../episode/same.wav", + "/api/v1/uploads/%2e%2e/episode/same.wav", "/api/v1/file/%2e%2e/uploads/same.wav?workspace=original", + "/api/v1/uploads/audio%5csame.wav", "/api/v1/uploads/same%00.wav", "/api/v1/uploads/%ZZ.wav", + "/api/v1/uploads/same.wav#ignored", "/api/v1/uploads//same.wav", +]) +def test_canonical_adoption_rejects_wrong_workspaces_and_traversal(media_roots, reference): + for root in media_roots: + (root / "same.wav").write_bytes(b"reference") + with pytest.raises(MediaPathNotAllowed): + resolve(reference, media_roots) + + +def test_canonical_adoption_does_not_follow_a_symlink_into_another_allowed_root(media_roots): + uploads, workspace = media_roots + (uploads / "same.wav").write_bytes(b"reference") + try: + (workspace / "same.wav").symlink_to(uploads / "same.wav") + except (OSError, NotImplementedError): + pytest.skip("Symlinks unavailable") + with pytest.raises(MediaPathNotAllowed): + resolve("/api/v1/file/same.wav?workspace=original", media_roots) + + +def test_legacy_adoption_remains_available_for_existing_audio_studio_callers(media_roots): + uploads, _ = media_roots + (uploads / "same.wav").write_bytes(b"reference") + assert resolve("same.wav", media_roots) == str(uploads / "same.wav") + assert resolve(str(uploads / "same.wav"), media_roots) == str(uploads / "same.wav") + + +def test_audio_adopt_endpoint_uses_source_workspace_and_new_canonical_resolver(media_roots, monkeypatch): + """Execute the actual lightweight route functions without importing GPU runtimes.""" + uploads, workspace = media_roots + monkeypatch.chdir(uploads.parent) + for root in media_roots: + (root / "same.wav").write_bytes(b"reference") + launch = Path(__file__).resolve().parents[1] / "app" / "_launch_runtime.py" + parsed = ast.parse(launch.read_text(encoding="utf-8")) + selected = [node for node in parsed.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name in {"_resolve_request_media_path", "adopt_audio"}] + for node in selected: + node.decorator_list = [] + seen_workspaces = [] + + def workspace_dir(name): + seen_workspaces.append(name) + return str(workspace) + + namespace = {"os": os, "quote": quote, "Request": object, "HTTPException": HTTPException, + "resolve_permitted_media_path": resolve_permitted_media_path, "MediaPathNotAllowed": MediaPathNotAllowed, + "_workspace_dir": workspace_dir, "_get_active_workspace": lambda: "destination", + "_probe_audio_duration": lambda _: 5} + exec(compile(ast.Module(body=selected, type_ignores=[]), str(launch), "exec"), namespace) + + class Request: + async def json(self): + return {"audio_path": "/api/v1/file/same.wav?workspace=original", "workspace": "original"} + + result = asyncio.run(namespace["adopt_audio"](Request())) + assert seen_workspaces == ["original"] + assert result["path"] == str(workspace / "same.wav") + assert result["duration_seconds"] == 5 + assert result["url"].endswith("?workspace=original") diff --git a/ui/src/features/characters/CharacterDefinitionEditor.tsx b/ui/src/features/characters/CharacterDefinitionEditor.tsx index d9a9f7282..f83e4d93a 100644 --- a/ui/src/features/characters/CharacterDefinitionEditor.tsx +++ b/ui/src/features/characters/CharacterDefinitionEditor.tsx @@ -11,7 +11,7 @@ import { sourceRefFromOutput } from '../scene3d/slotSource' import { characterFromSlot, characterSlotPatch } from '../scene3d/speech/characterBinding' import { modelDigest } from '../scene3d/speech/profiles' import { CharacterVoiceFields } from './CharacterVoiceFields' -import type { CharacterVoice } from '../../lib/characterVoice' +import { isCharacterVoiceReady, type CharacterVoice } from '../../lib/characterVoice' import { randomUuid } from '../../lib/uuid' import { CharacterDefinitionSpeechTools } from './CharacterDefinitionSpeechTools' import type { CharacterDefinitionDraft } from './characterEditorHandoff' @@ -80,13 +80,15 @@ function ScopedDefinition({ workspace, slot, disabled, initialKit, onSaved, onAp const [model, setModel] = useState(initialDraft?.model), [items, setItems] = useState([]) const [busy, setBusy] = useState(false), [notice, setNotice] = useState('') const [workshopDirty, setWorkshopDirty] = useState(false), [workshopBusy, setWorkshopBusy] = useState(false) + const [voiceBusy, setVoiceBusy] = useState(false) + const speechBusy = workshopBusy || voiceBusy const workshopSave = useRef(null) const saving = useRef(false) const alive = useRef(true) const hasSlot = Boolean(slot) const kit = definitionKit(library, id, initialKit) const dirty = Boolean(kit && (name !== kit.name || JSON.stringify(voice) !== JSON.stringify(kit.voice) || model)) - useEffect(() => { onBusyChange?.(busy || workshopBusy); return () => onBusyChange?.(false) }, [busy, workshopBusy, onBusyChange]) + useEffect(() => { onBusyChange?.(busy || speechBusy); return () => onBusyChange?.(false) }, [busy, speechBusy, onBusyChange]) useEffect(() => { onDirtyChange?.(dirty || workshopDirty); return () => onDirtyChange?.(false) }, [dirty, workshopDirty, onDirtyChange]) useEffect(() => { onDraftChange?.({ name, voice, model }) }, [name, voice, model, onDraftChange]) useEffect(() => { @@ -103,7 +105,8 @@ function ScopedDefinition({ workspace, slot, disabled, initialKit, onSaved, onAp void task().catch(error => { if (alive.current) setNotice(error.message) }).finally(() => { if (alive.current) setBusy(false) }) } const saveAll = async () => { - if (saving.current || workshopBusy || !canSaveDefinition(library, name, slot)) throw new Error(t('speech.busy')) + if (saving.current || speechBusy || !canSaveDefinition(library, name, slot)) throw new Error(t('speech.busy')) + if (!isCharacterVoiceReady(voice)) throw new Error(t('speech.customVoice.incomplete')) saving.current = true; setBusy(true); setNotice('') try { const update = (current?: CharacterKit) => definitionForSave(workspace, name, voice, current, slot, model) @@ -125,7 +128,7 @@ function ScopedDefinition({ workspace, slot, disabled, initialKit, onSaved, onAp

{t('speech.characterDefinition')}

{t('speech.definitionHint')}

- { setId(nextId); setNotice('') if (!slot) { setName(next?.name ?? ''); setVoice(next?.voice); setModel(undefined) } @@ -138,14 +141,14 @@ function ScopedDefinition({ workspace, slot, disabled, initialKit, onSaved, onAp - { + { setVoice(next) - if (slot) onApply?.({ character: { id: slot.character?.id ?? slot.id, name: slot.character?.name ?? name, ...slot.character, voice: next } }) + if (slot && isCharacterVoiceReady(next)) onApply?.({ character: { id: slot.character?.id ?? slot.id, name: slot.character?.name ?? name, ...slot.character, voice: next } }) }} /> - + {busy && } + {filename &&