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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y1oQ4h4WE4R9KRvMQLr5Ey
This commit is contained in:
steve-adminandClaude Sonnet 5 committed 2026-09-16 10:48:39 +00:00
1 parent 7569cdc322
commit 5f97f983fe
1 file changed
+88
+88
View File
@@ -485,6 +485,65 @@ def load_character(name: str):
return info, ref_path 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( def create_character(
name: str, role: str, prose: str, language: str, name: str, role: str, prose: str, language: str,
current_ref: str, history: list, current_ref: str, history: list,
@@ -775,6 +834,20 @@ with gr.Blocks(title="Audio-demo pipeline studio") as demo:
char_info = gr.Markdown() char_info = gr.Markdown()
char_audio = gr.Audio(label="Reference audio", type="filepath", interactive=False) 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") gr.Markdown("## Generate a new emotion candidate")
with gr.Row(): with gr.Row():
emotion_char_dd = gr.Dropdown(label="Character", choices=character_names()) 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], outputs=[char_dd, emotion_char_dd],
) )
char_dd.change(load_character, inputs=[char_dd], outputs=[char_info, char_audio]) 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( gen_emotion_btn.click(
generate_emotion_candidate, generate_emotion_candidate,
inputs=[emotion_char_dd, emotion_word_tb, emotion_model_dd], inputs=[emotion_char_dd, emotion_word_tb, emotion_model_dd],
outputs=[emotion_ref_audio, emotion_preview_audio, emotion_status], 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 # Dropdown choices are otherwise fixed at server-start time, so a
# character created earlier in the same running session (or by # character created earlier in the same running session (or by