When using local audio files with tts it shows a b...
# ask-ai
a
When using local audio files with tts it shows a break while using AgentSession.say as a welcome phrase
t
Thanks for reaching out, Antonio. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I’m sorry you’re seeing a break when you play a welcome phrase with
AgentSession.say
. I want to help get this fixed. To pinpoint what’s happening I need a few details about your setup and what you see; once I have that I can suggest targeted fixes and workarounds. Quick clarifying questions • Are you using a local audio file as the welcome phrase (pre-recorded) or calling TTS via
AgentSession.say
to synthesize the phrase? • If you’re using a local file, what format is it (e.g., WAV, MP3), sample rate, and bit depth? • Which TTS provider or engine is configured for the agent (built-in LiveKit TTS, external service, etc.)? • Where does the break occur: at the start of playback, mid-way, or when switching from TTS to the local file? Please describe the timing (for example: “there’s a 200–500ms gap before playback starts” or “playback stops after 1–2 seconds”). • Are you calling any other audio methods (like stopping/starting tracks,
allow_interruptions
or explicit
stop
calls) right before/after
AgentSession.say
? • Can you share the short snippet of code that triggers the welcome audio (redact any secrets)? A minimal example helps a lot. • Which LiveKit SDK version and agent runtime are you on, and are you running this locally or in a hosted environment? What I can try/help with immediately • If the welcome is a local file: confirm the file format is supported and encoded correctly (prefer WAV/PCM or constant bitrate MP3) and try resampling to 16 kHz or 48 kHz if you see glitches. • If mixing TTS and local files: the agent’s TTS playback and local-file playback may be scheduled or swapped; I can show how to sequence playbacks so there’s no gap once I see your code. • Check
allow_interruptions
or similar session flags — these can cause the agent to pause or drop playback when new audio is queued. If you share your config I’ll point out exact flags to change. • Provide a short reproducible test you can run locally to verify fixes (I’ll craft that once I see your code and environment). Next step Reply with the answers to the questions above and (if possible) the relevant code snippet and file details. I’ll review them and send concrete steps or a small code example to eliminate the break. Sources: Agent speech and audio | LiveKit Documentation
a
Copy code
The little break occurs in the middle at the speak: This is mi current code: ############################### INICIALIZACIÓN DE SERVIDOR LIVEKIT Y LIVEAVATAR ###############################
    
class Assistant(Agent):
    def __init__(self) -> None:
        super().__init__(
            instructions="",
        )

    


server = AgentServer(port=os.getenv("PRODUCTION_PORT"))
#Configuración voz TTS
setVoices = elevenlabs.VoiceSettings(stability=0.68,similarity_boost=0.54,style=0.47,speed=0.9,use_speaker_boost=True); 



@FlashCall Server.rtc_session()
async def my_agent(ctx: JobContext):
    # Logging setup
    # Add any other context you want in all log entries here
    ctx.log_context_fields = {
        "room": ctx.room.name,
    }
 
    
    
    # Set up a voice AI pipeline using OpenAI, Cartesia, AssemblyAI, and the LiveKit turn detector
    session = AgentSession(
        
        tts=elevenlabs.TTS(
            model="eleven_flash_v2_5", voice_id="ERYLdjEaddaiN9sDjaMX", voice_settings=setVoices, language="es"
        ),
        userdata={}
        
    )

    

    #Cuando el usuario se desconecte, se detiene la sesión y el avatar
    @session.on("close")
    def on_close(close_event):
        print("Sesión cerrada, deteniendo avatar y borrando conversationID", session)
        




      

    # # Add a virtual avatar to the session, if desired
    # # For other providers, see <https://docs.livekit.io/agents/models/avatar/>
    avatar = liveavatar.AvatarSession(
       avatar_id=os.getenv('LIVEAVATAR_AVATAR_ID'),  # See <https://docs.livekit.io/agents/models/avatar/plugins/hedra>
     )
    
    # # Start the avatar and wait for it to join
    await avatar.start(session, room=ctx.room)
     
    # Start the session, which initializes the voice pipeline and warms up the models
    await session.start(
        agent=Assistant(),
        room=ctx.room,
        room_options=room_io.RoomOptions(
            text_input=room_io.TextInputOptions(text_input_cb=custom_text_input_handler_tts)
        ),
        
    )
    
    
    
    #Saludamos al principio
    #await session.say("¡Hola!, soy Sophia. ¿En qué puedo ayudarte?", allow_interruptions=True)
    
    #precargamos el buffer de audio
    #await play_audio_once(session,saludo_text)
    await session.say("", allow_interruptions=True)
    await precargaBufferAudioInicio(session)
    await session.interrupt()
    await play_audio_once(session,saludo_text)
    await ctx.connect()
Copy code
############################### FUNCIONES DE AUDIO ###############################

# Función que precarga al comienzo los audios de espera
def precarga_audios_espera() -> dict[str, tuple[bytes, int, int, int]]:
    precarga: dict[str, tuple[bytes, int, int, int]] = {}

    #Añadimos la frase de saludo
    precarga[saludo_text] = wav_to_audio_frame(os.path.join(main_audio_path, saludo_path))
    #Ahora añadimos las frases de espera
    for i, frase in enumerate(longPhrases):
        audio_espera_path = f"espera{longPhrases.index(frase)}.wav"
        path = os.path.join(main_audio_path, audio_espera_path)
        precarga[frase] = wav_to_audio_frame(path)
    return precarga

async def precargaBufferAudioInicio(session:AgentSession):
    
    async def single_shot_audio_precharge():
        sample_rate = 48000
        num_channels = 1
        frame_duration = 0.02
        frame_size = int(sample_rate * frame_duration)  # Tamaño del frame para 1.5 segundos de audio
        bytes_per_sample = 2  # 16 bits = 2 bytes
        chunk_size = frame_size * num_channels * bytes_per_sample
        try:
            for _ in range(10): #200ms
                yield rtc.AudioFrame(
                data=b"\x00" * chunk_size,
                sample_rate=sample_rate,
                num_channels=num_channels,
                samples_per_channel=frame_size
                )
                #await asyncio.sleep(frame_duration)
        except asyncio.CancelledError:
            return

    session.say(audio=single_shot_audio_precharge(), text="",allow_interruptions=True)
    print("salimos de precarga")
    

#Convertir archivo WAV a rtc audioFrame para usar con el agente
def wav_to_audio_frame(path: str)-> tuple[bytes, int, int, int]:
    #MP3 to mono, 16-bit, PCM, 48kHz
    audio: AudioSegment = AudioSegment.from_wav(path)
    audio = audio.set_channels(1)
    audio = audio.set_sample_width(2)
    audio = audio.set_frame_rate(48000)

    raw_data = audio.raw_data
    num_channels = 1
    samples_per_channel = len(raw_data) // (2 * num_channels)
    return raw_data, 48000, 1, samples_per_channel
precarga_audios = precarga_audios_espera() #Cargamos los audios al iniciar el servidor    
print("Audios de espera precargados: ", list(precarga_audios.keys())) 

async def single_shot_audio(audio_text: str):
    #loop = asyncio.get_event_loop()
    raw, sample_rate, num_channels, samples_per_channel = precarga_audios[audio_text]
    frame_size = int(sample_rate * 0.02)  # Tamaño del frame para 1.5 segundos de audio
    bytes_per_sample = 2  # 16 bits = 2 bytes
    chunk_size = frame_size * num_channels * bytes_per_sample
    try:

        yield rtc.AudioFrame(
        data=b"\x00" * chunk_size,
        sample_rate=sample_rate,
        num_channels=num_channels,
        samples_per_channel=frame_size
        )

        await asyncio.sleep(0.02)  # Esperar un poco antes de comenzar a enviar los chunks

        for i in range(0, len(raw), chunk_size):
            chunk = raw[i:i + chunk_size]
            if(len(chunk) < chunk_size):
                chunk += b'\x00' * (chunk_size - len(chunk))  # Rellenar con ceros si el último chunk es más pequeño
            yield rtc.AudioFrame(
                data=chunk,
                sample_rate=sample_rate,
                num_channels=num_channels,
                samples_per_channel=frame_size
            )
            await asyncio.sleep(0.02)  # Esperar el tiempo del frame antes de enviar el siguiente
    except asyncio.CancelledError:
        return

async def play_audio_once(session: AgentSession,audio_text)-> None:    
    session.say(audio=single_shot_audio(audio_text), text=audio_text, allow_interruptions=True)
t
Thanks — this is very helpful. I looked through your audio preload and playback code and the sequence you use to warm up and play the welcome phrase. I’ll summarize what’s causing the mid-speech break and give concrete, small changes you can make to remove the gap. What I’m responding to • Your description that the break occurs in the middle of the welcome phrase. • The provided code:
precargaBufferAudioInicio
,
precarga_audios_espera
,
single_shot_audio
, and
play_audio_once
. • Your earlier sequence that used an empty
session.say("")
, precharge,
session.interrupt()
, then
play_audio_once
. Why the break is happening (short) • You send an intentional silent precharge stream (zero bytes) before the real audio; this can alter the audio pipeline state and introduce timing/gap issues. • You add an initial sleep before streaming actual audio frames, and you also sleep between frames. Those sleeps can create small audible gaps or cause underflow if the pipeline expects continuous frames. • Calling
session.interrupt()
or using an empty
say("")
can cancel/reset internal audio state or create a dangling speech task, producing a mid-utterance pause. • The chunking and frame timing (frame_size / chunk_size) must match what the audio pipeline expects; mismatches plus extra sleeps lead to interruptions. Immediate fixes to try (apply these first) 1. Remove the empty
session.say("", ...)
call. It doesn’t reliably warm up anything and can create an invalid speech task. 2. Remove the
session.interrupt()
right before playback. Interrupt cancels the current speech and will create an audible cut if used incorrectly. 3. Stream frames continuously with no unneeded initial sleep and avoid sleeping between frames unless you observe desync. Send frames back-to-back (the consumer will pace them). 4. Only send a minimal leading silence if strictly required (one small frame), not a multi-frame silent loop. Concrete code edits you can make • Replace your
precargaBufferAudioInicio
to avoid multi-frame silence and not call
session.say
with silence:
Copy code
async def precargaBufferAudioInicio(session: AgentSession):
    # If you want a single small silence to ensure audio pipeline is ready:
    async def tiny_precharge():
        sample_rate = 48000
        num_channels = 1
        frame_duration = 0.02
        frame_size = int(sample_rate * frame_duration)
        chunk_size = frame_size * num_channels * 2
        try:
            # send a single short silent frame, not many
            yield rtc.AudioFrame(
                data=b"\x00" * chunk_size,
                sample_rate=sample_rate,
                num_channels=num_channels,
                samples_per_channel=frame_size
            )
        except asyncio.CancelledError:
            return

    # Do NOT call session.say with empty text; use a single tiny precharge if needed
    await session.say(audio=tiny_precharge(), text="", allow_interruptions=True)
    print("salimos de precarga")
• Update
single_shot_audio
to stream frames without initial/extra sleeps:
Copy code
async def single_shot_audio(audio_text: str):
    raw, sample_rate, num_channels, _ = precarga_audios[audio_text]
    frame_size = int(sample_rate * 0.02)
    bytes_per_sample = 2
    chunk_size = frame_size * num_channels * bytes_per_sample

    try:
        # Optional: one small leading silent frame (remove if not needed)
        yield rtc.AudioFrame(
            data=b"\x00" * chunk_size,
            sample_rate=sample_rate,
            num_channels=num_channels,
            samples_per_channel=frame_size
        )

        # Stream the audio frames continuously, without await asyncio.sleep
        for i in range(0, len(raw), chunk_size):
            chunk = raw[i:i + chunk_size]
            if len(chunk) < chunk_size:
                chunk += b'\x00' * (chunk_size - len(chunk))
            yield rtc.AudioFrame(
                data=chunk,
                sample_rate=sample_rate,
                num_channels=num_channels,
                samples_per_channel=frame_size
            )
            # remove this sleep; the audio pipeline will handle pacing
            # await asyncio.sleep(0.02)
    except asyncio.CancelledError:
        return
• Ensure you await the
session.say
call so the audio task is scheduled before connecting:
Copy code
await session.say(audio=single_shot_audio(saludo_text), text=saludo_text, allow_interruptions=False)
Set
allow_interruptions=False
for the initial greeting so it is not cut by anything else. Other suggestions if you still see gaps • Make sure your WAV → raw bytes conversion exactly matches the frame format (mono, 48kHz, 16-bit). Your
wav_to_audio_frame
looks correct but double-check file encoding and that
raw_data
length divides cleanly into chunks. • If you need to control pacing because receiver expects a live cadence, consider a small buffering strategy in the audio source (larger queue) rather than sleeps: increase the consumer queue or buffer size so transient scheduling delays don’t cause underflow. • If you are mixing TTS streaming and local file playback, pre-initialize your TTS instance at process start (rather than using an empty say to warm up) so the TTS stream doesn’t introduce a cold-start delay. Recommended test steps 1. Remove
session.say("")
and remove
session.interrupt()
from your agent start sequence. 2. Use the adjusted
single_shot_audio
(no sleeps) and call
await session.say(..., allow_interruptions=False)
for the welcome phrase. 3. If the break disappears, you’ve confirmed interruption/timing was the issue. If a tiny gap remains only at the very start, try sending a single small silent frame as the tiny precharge (as above). If you want, paste the small test run you used after these changes (the exact
await
sequence and console output). I can review it and suggest one more tweak (e.g., buffer size or chunk_size adjustments) if any micro-gap remains. Sources: Resolving &amp;#34;InvalidState - failed to capture frame&amp;#34; errors in AudioSource | ... | Uninterruptable Agent | LiveKit Documentation | Playing Audio | LiveKit Documentation | Agent speech and audio | LiveKit Documentation | Speech-to-text (STT) models overview | LiveKit Documentation
a
How do I preinitialize the TTS
t
Thanks — you asked how to preinitialize the TTS so the first utterance doesn’t suffer a cold start. LiveKit does not provide a documented “prewarm TTS at process startup” hook the same way it does for some models (like VAD). However, you can get the same benefit by creating a single TTS instance once (at module/process level) and reusing that instance for every AgentSession. This avoids re-creating connections/models per session and reduces first-play latency. What to do (short) • Create your ElevenLabs TTS instance once when your server process starts. • Pass that same instance into each new AgentSession instead of creating a new TTS object per session. Concrete example using your ElevenLabs config 1. At module level (before you register rtc_session), create the TTS instance once:
Copy code
# module-level: create once
global_tts = elevenlabs.TTS(
    model="eleven_flash_v2_5",
    voice_id="ERYLdjEaddaiN9sDjaMX",
    voice_settings=setVoices,
    language="es",
    streaming_latency=2,             # optional: reduce latency
    chunk_length_schedule=[50,100,150]  # optional: shorter chunks
)
1. Reuse that instance when you create each AgentSession:
Copy code
session = AgentSession(
    tts=global_tts,
    preemptive_generation=True,   # optional: reduce perceived delay
    userdata={}
)
1. Start the session as before. Because
global_tts
is created once, subsequent sessions reuse the warmed connections/resources and avoid repeated cold starts. Notes and small tips • There’s no documented server.setup_fnc for TTS specifically, so creating the instance at module scope or in an initialization function that runs once per process is the practical approach. • Keep
streaming_latency
and
chunk_length_schedule
tuned for lower latency if you still experience small gaps. • If you ever need different voices per session, you can either create a small pool of pre-created TTS instances or instantiate per session only when necessary—prefer reusing the ones you need most. • Continue avoiding the empty
session.say("")
approach — it can create odd behavior. Use the shared instance as above and, if needed, send a single tiny silent frame (not multi-frame silence) to prime the pipeline. If you want, I can: • Show a full minimal startup pattern that creates the global TTS instance and the AgentServer setup. • Help you create a small pool if you need to support multiple voice_ids concurrently. Which would you prefer? Sources: Agent speech and audio | LiveKit Documentation | TTS Comparison | LiveKit Documentation | Simple Call Answering Agent | LiveKit Documentation | TTS Translator with Gladia STT | LiveKit Documentation | TTS Metrics | LiveKit Documentation
a
Okay, and how do I invoke the method AgentSession.say once a user join to the room
t
Thanks — good question. To make the agent speak a welcome phrase as soon as a user joins the room, use the agent lifecycle hook
on_enter
. That hook runs when the agent becomes active in a session, so placing
await self.session.say(...)
there will play the greeting immediately. A minimal pattern you can follow (fits into your existing server/session code): 1. Create an Agent subclass and override on_enter:
Copy code
class Assistant(Agent):
    async def on_enter(self):
        # Play a fixed welcome phrase (no interruptions so it won't be cut)
        await self.session.say("¡Hola! Soy Sophia. ¿En qué puedo ayudarte?", allow_interruptions=False)
1. Make sure your session uses the agent instance when starting:
Copy code
session = AgentSession(tts=global_tts, preemptive_generation=True, userdata={})
await session.start(agent=Assistant(), room=ctx.room, room_options=...)
await ctx.connect()
Notes and small tips • Set allow_interruptions=False for the initial greeting so it isn’t cut by other signals or precharge streams. • If you previously used a silent precharge or
session.interrupt()
in your startup sequence, remove those calls; they can interfere with the welcome playback. • If you reuse a global TTS instance (recommended), pass that into AgentSession so the first utterance is warmed and plays without cold-start gaps. If you want a dynamic greeting generated by the LLM instead of a fixed string, call:
Copy code
await self.session.generate_reply(instructions="Greet the user warmly and ask how you can help.")
and then let the session play the generated reply. If you want, paste the exact sequence you use to start the session and I’ll show the exact place to call the welcome message so it runs right when the first participant joins. Sources: Pipeline nodes and hooks | LiveKit Documentation | Amazon Nova Sonic integration guide | LiveKit Documentation
a
"message": "resume_false_interruption is enabled but audio output does not support pause, it will be ignored", "level": "WARNING", "name": "livekit.agents", "audio_output": "TranscriptSynchronizer", "room": "voice_assistant_room_8881", "timestamp": "2026-03-02T115212.326801+00:00"}
t
Thanks — that warning explains why your resume-after-false-interruption behavior isn’t working and how it can relate to the welcome audio break. I’ll explain what it means and give concrete, low-friction actions you can take. What the warning means • The agent setting
resume_false_interruption
(enabled by default) tries to resume TTS playback after a brief false interruption. • Your audio output component is
TranscriptSynchronizer
, which cannot pause and resume audio streams. Because it lacks pause/resume support, the resume behavior is ignored and you see the warning: "resume_false_interruption is enabled but audio output does not support pause, it will be ignored." • This mismatch is harmless by itself, but if your flow relies on pause/resume (for example using interrupts or silent precharges), the ignored resume can produce gaps or unexpected behavior in playback. Concrete ways to fix or mitigate the issue • Best immediate fix for the welcome break: avoid interruptions around the welcome playback. ◦ Use allow_interruptions=False for the initial greeting so nothing can interrupt it: await self.session.say("¡Hola!...", allow_interruptions=False) • Remove empty or multi-frame silent
session.say(...)
calls and avoid calling session.interrupt() right before the greeting. • If you want resume behavior to actually work: ◦ Use an audio output that supports pause/resume (TranscriptSynchronizer does not). If your setup supports swapping audio outputs, pick one with pause/resume capability. ◦ Or disable
resume_false_interruption
so you stop getting the warning and won’t depend on resume logic. (If there’s a config flag exposed in your agent/session code, set it to False; otherwise rely on allow_interruptions settings.) • Reuse a warmed TTS instance to remove cold-start gaps (complementary): ◦ Create the ElevenLabs TTS once at process/module startup and pass it into each AgentSession (tts=global_tts). This avoids first-utterance latency that can look like a gap. Small checklist to apply now (quick) 1. Remove: empty
session.say("")
and any immediate
session.interrupt()
before the welcome. 2. Change your welcome to: await self.session.say("¡Hola! Soy Sophia. ¿En qué puedo ayudarte?", allow_interruptions=False) 1. Reuse a global TTS instance so the first playback is warmed. 2. If you need resume-after-interrupt to work, switch to an audio output that supports pause/resume or disable resume behavior. If you want, I can: • Show the exact code to disable resume behavior (if your runtime exposes that flag), or • Show how to create and reuse a global ElevenLabs TTS instance in your server code, or • Help you pick/implement an audio output that supports pause/resume. Which of those would be most helpful right now? Sources: Context Variables | LiveKit Documentation | Playing Audio | LiveKit Documentation | Text and transcriptions | LiveKit Documentation | Turns overview | LiveKit Documentation
a
{"message": "Error in _recv_task", "level": "ERROR", "name": "livekit.plugins.liveavatar", "exc_info": "Traceback (most recent call last):\n File \"C:\\Users\\anton\\Documents\\TC_Sophia\\Sophia-Server\\.venv\\Lib\\site-packages\\livekit\\agents\\utils\\log.py\", line 16, in async_fn_logs\n return await fn(*args, **kwargs)\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"C:\\Users\\anton\\Documents\\TC_Sophia\\Sophia-Server\\.venv\\Lib\\site-packages\\livekit\\plugins\\liveavatar\\avatar.py\", line 258, in _recv_task\n raise APIConnectionError(message=\"LiveAvatar connection closed unexpectedly.\")\nlivekit.agents._exceptions.APIConnectionError: LiveAvatar connection closed unexpectedly. (body=None, retryable=True)", "room": "voice_assistant_room_2331", "timestamp": "2026-03-02T115524.314936+00:00"}
t
Thanks — that LiveAvatar error explains why your avatar dropped during the session. I’ll walk through what it means, likely causes, and concrete steps you can take right now to handle it and avoid interruptions to your welcome audio. What the error means • The LiveAvatar plugin’s receive task detected the avatar connection closed unexpectedly and raised an APIConnectionError. The error is marked retryable, so the plugin believes the connection can be re-established. • This happens inside the LiveAvatar real-time link (the code that streams avatar data and receives events), not in the audio pipeline itself. When it closes, the avatar stops and your session continues, which can coincide with audio timing issues. Likely causes • Network instability or dropped socket between your server and the LiveAvatar service (most common). • Invalid or expired LiveAvatar API key or permission errors. • LiveAvatar service outages or transient errors on the provider side. • Resource limits or timeouts in your environment causing the avatar connection to be closed. Immediate actions to take 1. Check network and credentials ◦ Confirm the environment variable with your LiveAvatar API key is set and valid. ◦ Verify your server can reach the LiveAvatar endpoint (simple curl or connection test from the host). 1. Add graceful handling and retry ◦ Wrap avatar startup in try/except, log the error, and retry or fallback to audio-only. This prevents the whole session from failing when the avatar connection drops. Example pattern:
Copy code
from livekit.agents._exceptions import APIConnectionError

try:
    avatar = liveavatar.AvatarSession(avatar_id=os.getenv('LIVEAVATAR_AVATAR_ID'))
    await avatar.start(session, room=ctx.room)
except APIConnectionError as e:
    # Log and continue with audio-only behavior
    logger.warning(f"Avatar connection failed: {e}. Continuing with audio-only mode.")
    avatar = None
1. Implement reconnect logic (exponential backoff) ◦ On a retryable APIConnectionError, attempt a few reconnects with incremental delays (for example 0.5s, 1s, 2s) before falling back permanently for that session. 1. Provide an audio-only fallback ◦ If avatar fails, make sure the agent still delivers the welcome phrase via
self.session.say(...)
(use allow_interruptions=False for the greeting). This keeps the user experience intact even without the avatar. 1. Monitor and alert ◦ Add logging and optionally alerting when the avatar connection fails repeatedly so you can detect service or network issues. Other recommendations tied to your audio break • A cold LiveAvatar disconnect can create timing differences; keep your welcome audio controlled by the agent
on_enter
hook and avoid precharge/interrupt sequences that rely on avatar lifecycle. Ensure the welcome is triggered regardless of avatar state:
Copy code
class Assistant(Agent):
    async def on_enter(self):
        # Play welcome regardless of avatar status
        await self.session.say("¡Hola! Soy Sophia. ¿En qué puedo ayudarte?", allow_interruptions=False)
• Reuse a global TTS instance to minimize cold-start audio gaps (create the ElevenLabs TTS once at process start and pass it into AgentSession). This ensures the welcome audio plays smoothly even if the avatar reconnects. Troubleshooting checklist • [ ] Verify LIVEAVATAR_API_KEY and avatar_id are correct. • [ ] Confirm network connectivity from your server to LiveAvatar endpoints. • [ ] Add try/except around avatar.start and implement reconnect/backoff. • [ ] Ensure the agent always issues the welcome
session.say()
in
on_enter
so users hear it even if avatar is down. • [ ] Monitor logs for repeated APIConnectionError events and raise alerts if frequent. If you’d like, I can: • Provide a full retry/backoff snippet to plug into your code. • Help craft an audio-only fallback flow that triggers automatically when avatar fails. Which would be most useful right now? Sources: OpenAI TTS plugin guide | LiveKit Documentation | Bringing AI avatars to voice agents | Diagnosing Connection Errors with the Connection Test Utility | LiveKit | LiveAvatar virtual avatar integration guide | LiveKit Documentation | client-sdk-js | livekit/livekit_composite | DeepWiki