File size: 4,019 Bytes
46bd2ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
from __future__ import annotations

import gradio as gr

from config import CONFIG
from assistant import bootstrap, chat, stt, tts, ui

UI = CONFIG.pack["ui"]
PROFILE_EXAMPLE = CONFIG.pack["profile_example"]


with gr.Blocks(title="Amigo") as demo:
    state = gr.State([])

    gr.HTML(
        f'<div id="amigo-head"><h1>{UI["title"]}</h1>'
        f'<p>{UI["subtitle"]}</p></div>'
    )
    avatar = gr.HTML(ui.avatar("idle"))

    # The one action: a big tap-to-talk button right under the avatar. CSS turns
    # Gradio's audio widget into a single round button (see assistant/ui.py).
    mic = gr.Audio(
        sources=["microphone"], type="filepath",
        label=UI["mic"], elem_id="amigo-mic",
    )
    # Non-streaming: one clip per turn. A streaming=True output only plays on
    # the first turn (Gradio reuses the component's MediaSource and never
    # reinitializes it), so each turn sets a fresh full-reply clip instead.
    reply_audio = gr.Audio(
        label=UI["reply"], autoplay=True,
        interactive=False, elem_id="amigo-reply",
    )

    # The transcript, as a reading aid. A placeholder fills the empty space.
    chatbot = gr.Chatbot(
        height=280, label="", elem_id="amigo-chat", show_label=False,
        placeholder=UI["chat_placeholder"],
    )

    # Setup lives at the bottom, collapsed: the editable profile, prefilled with
    # a neutral example. Nothing is predefined; it feeds every turn's prompt.
    with gr.Accordion(UI["profile_label"], open=False):
        gr.Markdown(UI["profile_help"])
        profile_box = gr.Code(
            value=PROFILE_EXAMPLE, language="yaml", label="",
            elem_id="amigo-profile",
        )

    def on_listen():
        """Mic started: show the listening orb."""
        return ui.avatar("listening")

    def handle_turn(audio_path, history, profile_text):
        """Transcribe, stream the text live, then speak the full reply.

        Text streams token-by-token so he sees the answer forming; the voice
        plays once the reply is complete, which is a few seconds for a short
        answer. `profile_text` is the YAML from the editor above. Dict-form
        yields touch only the named components.
        """
        history = history or []
        yield {avatar: ui.avatar("thinking")}

        user_text = stt.transcribe(audio_path)
        if not user_text:
            yield {avatar: ui.avatar("idle", UI["not_heard"])}
            return

        history = history + [{"role": "user", "content": user_text}]
        history.append({"role": "assistant", "content": ""})

        full = ""
        for chunk in chat.respond(user_text, history[:-2], profile_text):
            full += chunk
            history[-1]["content"] = full
            yield {chatbot: history, avatar: ui.avatar("talking")}

        sr, samples = tts.synth(tts.clean_text(full))
        yield {chatbot: history, reply_audio: (sr, samples),
               avatar: ui.avatar("idle")}

    mic.start_recording(on_listen, outputs=[avatar])
    (
        mic.stop_recording(
            handle_turn,
            inputs=[mic, state, profile_box],
            outputs=[chatbot, reply_audio, avatar],
        )
        .then(lambda h: h, inputs=chatbot, outputs=state)
        # Clear the mic so it drops its recorded clip and shows the big record
        # button again. Without this the audio widget stays in playback/edit
        # mode on the last take, and he can't start a new turn. Runs on every
        # path (including "not heard"), so a failed turn never locks him out.
        .then(lambda: gr.update(value=None), outputs=mic)
    )


if __name__ == "__main__":
    # On a Space this downloads the models on first boot; locally it's a no-op.
    bootstrap.ensure_models()
    # Warm the model + embeddings so the first turn isn't cold.
    print("Calentando modelos…")
    chat.llm.warmup()
    chat.memory.warmup()
    print("Listo.")
    # Gradio 6: theme + css belong on launch().
    demo.launch(theme=ui.THEME, css=ui.CSS)