From 5f97f983fe2b1e4d21cef82e50ec6e6091ec7f03 Mon Sep 17 00:00:00 2001 From: Steve Calcott Date: Wed, 16 Sep 2026 10:48:39 +0000 Subject: [PATCH] Add "Clone text as this character" to the Characters tab Type any text and hear the selected character say it, in their base voice or any approved/pending emotion. Generalizes two things at once: testing an emotion candidate with real dialogue instead of the fixed "Testing the X emotion for Y" line, and just answering "how would they say this?" for the approved voice directly from the browser without switching to the Pipeline Studio tab. list_character_voices() enumerates every voice available for a character (base + approved emotion_library entries + pending emotion-cache candidates) - the voice dropdown refreshes whenever the character selection changes, and also right after generating a new emotion candidate so it is immediately pickable without reselecting the character. Every clone gets the same Whisper transcript check as everywhere else in this app. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Y1oQ4h4WE4R9KRvMQLr5Ey --- webui/app.py | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/webui/app.py b/webui/app.py index 9954e80..a495350 100644 --- a/webui/app.py +++ b/webui/app.py @@ -485,6 +485,65 @@ def load_character(name: str): return info, ref_path +def list_character_voices(name: str) -> dict[str, str]: + """label -> reference audio path, for every voice available for a + character: the base voice, every approved emotion_library entry, and + every pending (not yet approved) emotion-cache candidate. Used both by + the clone-as-character tool below and could back a similar picker + anywhere else a character's variants need listing.""" + cards = read_character_cards() + card = cards.get(name) + if not card or "_error" in card: + return {} + voices = {} + ref_audio = card.get("reference_audio") + if ref_audio: + voices["Base (natural voice)"] = str(SERVICE_STATION_DIR / ref_audio) + for emo, entry in (card.get("emotion_library") or {}).items(): + ref = entry.get("reference_audio") + if ref: + voices[f"{emo} (approved)"] = str(SERVICE_STATION_DIR / ref) + cache_dir = card["_dir"] / "emotion-cache" + if cache_dir.is_dir(): + for p in sorted(cache_dir.glob("*.wav")): + voices[f"{p.stem} (cached, not approved)"] = str(p) + return voices + + +def refresh_voice_choices(name: str): + voices = list_character_voices(name) + labels = list(voices.keys()) + return gr.update(choices=labels, value=labels[0] if labels else None) + + +def clone_as_character(character_name: str, voice_label: str, text: str, language: str): + if not character_name: + raise gr.Error("Pick a character first.") + if not voice_label: + raise gr.Error("Pick a voice (base or one of its emotions).") + if not text or not text.strip(): + raise gr.Error("Enter some text for them to say.") + + voices = list_character_voices(character_name) + reference = voices.get(voice_label) + if not reference: + raise gr.Error(f"Could not resolve '{voice_label}' for {character_name} - try Refresh.") + + _free_comfyui_memory() + env = None + if TTS_LD_LIBRARY_PATH: + env = os.environ.copy() + env["LD_LIBRARY_PATH"] = TTS_LD_LIBRARY_PATH + out_path = WORKDIR / f"clone_{int(time.time())}.wav" + _run( + [str(TTS_PYTHON), str(TTS_GENERATE), "--text", text.strip(), + "--reference", reference, "--language", language, "--output", str(out_path)], + cwd=TTS_DIR, env=env, + ) + transcript = _whisper_transcribe(str(out_path)) + return str(out_path), transcript + + def create_character( name: str, role: str, prose: str, language: str, current_ref: str, history: list, @@ -775,6 +834,20 @@ with gr.Blocks(title="Audio-demo pipeline studio") as demo: char_info = gr.Markdown() char_audio = gr.Audio(label="Reference audio", type="filepath", interactive=False) + gr.Markdown( + "## Clone text as this character\n" + "Say anything in the selected character's voice - the base voice, an " + "approved emotion, or a pending (not yet approved) candidate from the " + "cache. Useful both for \"how would they say this?\" and for testing an " + "emotion candidate with real dialogue instead of the fixed test line below." + ) + clone_voice_dd = gr.Dropdown(label="Voice", choices=list(list_character_voices(None).keys())) + clone_text_tb = gr.Textbox(label="Text to speak", lines=3, placeholder="Type anything.") + clone_language_dd = gr.Dropdown(LANGUAGES, value="English", label="Language") + clone_btn = gr.Button("Clone", variant="primary") + clone_audio = gr.Audio(label="Cloned speech", type="filepath", interactive=False) + clone_transcript_box = gr.Textbox(label="Whisper transcript") + gr.Markdown("## Generate a new emotion candidate") with gr.Row(): emotion_char_dd = gr.Dropdown(label="Character", choices=character_names()) @@ -793,10 +866,25 @@ with gr.Blocks(title="Audio-demo pipeline studio") as demo: outputs=[char_dd, emotion_char_dd], ) char_dd.change(load_character, inputs=[char_dd], outputs=[char_info, char_audio]) + char_dd.change(refresh_voice_choices, inputs=[char_dd], outputs=[clone_voice_dd]) + clone_btn.click( + clone_as_character, + inputs=[char_dd, clone_voice_dd, clone_text_tb, clone_language_dd], + outputs=[clone_audio, clone_transcript_box], + ) gen_emotion_btn.click( generate_emotion_candidate, inputs=[emotion_char_dd, emotion_word_tb, emotion_model_dd], outputs=[emotion_ref_audio, emotion_preview_audio, emotion_status], + ).then( + # So a freshly-generated candidate is immediately pickable in + # "Clone text as this character" without reselecting the + # character to force a refresh. Keyed off char_dd (the + # browser above), not emotion_char_dd - the clone tool + # always uses whichever character char_dd is showing, and + # those two dropdowns can legitimately point at different + # characters at once. + refresh_voice_choices, inputs=[char_dd], outputs=[clone_voice_dd], ) # Dropdown choices are otherwise fixed at server-start time, so a # character created earlier in the same running session (or by