Is there a way to stop routing LLM output to the T...
# ask-ai
s
Is there a way to stop routing LLM output to the TTS node entirely (not just mute playback) so that TTS is never invoked when sound is off?
t
Thanks for reaching out, Anmy. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Acknowledging your question about preventing LLM output from ever invoking TTS when sound is off. Yes — disable audio output at the session level so TTS is never called. When you start the AgentSession set
audioEnabled: false
in `outputOptions`:
Copy code
await session.start({
  agent,
  room: ctx.room,
  outputOptions: {
    audioEnabled: false,
  },
});
With
audioEnabled: false
the agent will not invoke the TTS system, no audio tracks are published, and text responses still arrive via the
lk.transcription
text stream. Would you like a snippet showing how to toggle this dynamically at runtime? Sources: Text and transcriptions | LiveKit docs
s
where should i import name outputoptions
t
You're not required to import a separate
OutputOptions
type/object. • For Node.js/TypeScript: import the voice agent module and pass the options inline:
Copy code
import { voice } from '@livekit/agents';

await session.start({
  room: ctx.room,
  agent: myAgent,
  outputOptions: {
    audioEnabled: false,
  },
});
• For Python: use
room_io.RoomOptions
from the voice agents package:
Copy code
from livekit.agents.voice import room_io

await session.start(
  room=ctx.room,
  agent=my_agent,
  room_options=room_io.RoomOptions(
    audio_output=False
  )
)
If you want TypeScript type hints for
outputOptions
, tell me whether you're using plain JS or TS and I’ll provide the exact type import (if available). Sources: Agent session | LiveKit docs | Text and transcriptions | LiveKit docs
s
I want like only when user clicks sound button off the llm to stop sending output to tts
t
Responding to your request about only stopping TTS when the user clicks the sound-off button. Use the session's runtime toggle for audio output. Call the session output API when the button is clicked so the agent stops invoking TTS without ending the session. Short examples: • TypeScript (frontend RPC -> backend):
Copy code
// frontend: call RPC when button toggled
await room.localParticipant.sendRpc('toggle_sound', { enabled: false });

// backend: registered RPC handler
ctx.room.localParticipant.registerRpcMethod('toggle_sound', async (data) => {
  session.output.setAudioEnabled(data.enabled);
});
• Python (backend RPC handler):
Copy code
@ctx.room.local_participant.register_rpc_method('toggle_sound')
async def toggle_sound(data):
    session.output.set_audio_enabled(data['enabled'])
Notes: • Ensure the session started with audio enabled so you can toggle it later. • While audio is disabled, text replies still arrive via `lk.transcription`; TTS will not be invoked or published. Do you want a full frontend example for your stack (React/Vue/Flutter) to wire the button to the RPC? Sources: Text and transcriptions | LiveKit docs | Agent session | LiveKit docs
s
but here when we use session.output.set_audio_enabled(False) TTS is still producing output audio right? I want to reduce the cost soo my aim is to stop tts
t
You're right to confirm — calling
session.output.set_audio_enabled(False)
(or starting the session with
audioEnabled: false
) stops the TTS pipeline from being invoked. It does not merely mute playback. • TTS synthesis is not performed when audio is disabled. • No audio tracks are published and you won't incur TTS processing costs. • Text replies continue to be sent via the
lk.transcription
stream. If you'd like, I can provide the exact code snippet for your stack (TS or Python) to toggle this from a UI button and ensure the session was started so toggling is valid. Which stack are you using? Sources: Text and transcriptions | LiveKit docs
s
But audio is generating right from TTS even if we session.output.set_audio_enabled(False)
t
You're right to be concerned — if you're still hearing TTS after calling
session.output.set_audio_enabled(False)
, let's troubleshoot quickly so we can stop TTS synthesis and costs. Quick checklist — please confirm each item and share short answers or a code snippet: 1. Where are you calling the toggle? Is
session.output.set_audio_enabled(False)
(Python) or
session.output.setAudioEnabled(false)
(TS) being called after the session is started and before/while the LLM is about to speak? 2. Is any speech already queued or in-progress when you toggle? If so, call
session.interrupt()
immediately after disabling audio to stop in-flight synthesis/playback. 3. Which SDK and version are you using (Node/Python + version)? 4. How are you verifying TTS audio is being produced? (Hearing audio in client, seeing audio tracks published, observing TTS metrics/logs) 5. Are you calling any other APIs that could synthesize or publish audio (e.g.,
say()
with pre-synthesized audio, BackgroundAudioPlayer, or external TTS calls)? 6. Do you have any frontend code that might be playing cached or fallback audio from
lk.transcription
or other sources? Short-term fixes to try now: • After disabling audio, call interrupt to stop any ongoing TTS: ◦ Python:
session.interrupt()
◦ TypeScript:
session.interrupt()
• Add logging around
session.output.audio_enabled
and TTS-related metrics/events to confirm
tts_node
is not being invoked. • Verify the session was originally started with audio enabled so toggling is allowed. If you share your SDK/version and the small code path where you toggle (and how you detect the audio), I’ll point out exactly where to change it or provide a minimal reproducible snippet to ensure TTS is never invoked. Sources: Text and transcriptions | LiveKit docs | Agent speech and audio | LiveKit docs | Agents (Node.js) | livekit/livekit_composite | DeepWiki | Observability data hooks | LiveKit docs
s
No like when we do session.output.set_audio_enabled(False) this TTS is still generating the audio but not giving to the client is that right?....My aim is to stop TTS from working when user turns off the sound
t
Thanks — I hear you: you want to ensure TTS stops being synthesized at all when the user turns sound off (not just blocked from the client). Let’s confirm exactly what’s happening and fix it. Short answer: calling
session.output.set_audio_enabled(False)
should stop TTS synthesis entirely — it does not merely mute playback. If you are seeing TTS still produce audio (even if not sent to the client), that indicates either in-flight speech, another TTS path still active, or an implementation detail we need to identify. Please provide the following so I can pinpoint the cause quickly: 1. SDK and exact version (Node or Python). 2. Minimal code snippet showing: ◦ How you start the session (outputOptions / room_options). ◦ Where and when you call
set_audio_enabled(False)
(or
setAudioEnabled(false)
). ◦ Any calls to
say()
, BackgroundAudioPlayer, or other TTS APIs. 1. How you observe “TTS is still generating audio” — hearing audio, server logs, TTS metrics, or saved files? 2. Whether speech was already in-progress when you toggled sound off. Quick troubleshooting steps to try now: • Ensure you call
session.output.set_audio_enabled(False)
after the session is started and before the LLM begins generating speech. • If speech may already be in progress, call
session.interrupt()
immediately after disabling audio to stop queued/in-flight synthesis. • Check for other TTS calls (manual
say()
with pre-synthesized audio or external TTS usage). • Verify TTS metrics/events/logs: if TTSMetrics events continue after disabling audio, share those log lines. Once you share the SDK/version and snippet, I’ll give an exact code fix (or alternate method) to guarantee TTS synthesis is not invoked when the user turns sound off.
s
its working like the audio is getting muted but i want to know if TTS is working
t
You're asking whether TTS actually runs when you disable audio (it appears muted) and how to verify that—good question. Facts: • Disabling audio with
session.output.set_audio_enabled(False)
stops the TTS pipeline from being invoked and prevents audio synthesis and publishing. It is not supposed to merely mute playback. How to verify whether TTS is running in your setup (quick checks you can run now): 1. Inspect TTS metrics/events/logs ◦ Check for any TTS-related metrics or events (e.g., TTSMetrics). If they stop after disabling audio, TTS is not being invoked. 1. Add server-side logging around the TTS node ◦ Log when
tts_node()
or any internal TTS call is reached. 1. Watch for published audio tracks ◦ If no audio tracks are published after disabling audio, synthesis/publishing is disabled. 1. Check for queued/in-flight speech ◦ If speech was already queued, call
session.interrupt()
after disabling audio to cancel in-progress synthesis. 1. Search your code for alternate TTS paths ◦ Confirm you’re not calling
say()
with pre-synthesized audio, BackgroundAudioPlayer, or external TTS services. If you want, paste: • SDK (Node/Python) and version, • The short code path where you start the session and toggle audio, • Any logs showing TTS activity, and I’ll point to the exact log lines or minimal code change to prove TTS is not running. Would you like to proceed with that?
s
from future import annotations import json import asyncio from contextlib import asynccontextmanager from typing import AsyncIterator import uvicorn import logging import os import certifi import time import multiprocessing from livekit.plugins import deepgram, groq, elevenlabs from dotenv import load_dotenv from custom_deepgram_tts import CustomDeepgramTTS from livekit.agents.voice import UserStateChangedEvent, AgentStateChangedEvent from livekit.agents.voice.io import PlaybackFinishedEvent from livekit import api, rtc from livekit.agents import ( tts, Agent, AgentSession, JobContext, llm, voice, WorkerOptions, cli, room_io, RunContext, RoomInputOptions, RoomOutputOptions, ) from livekit.plugins import silero, noise_cancellation from livekit.agents import metrics, MetricsCollectedEvent, ConversationItemAddedEvent from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware # Set SSL_CERT_FILE to use certifi's certificate bundle os.environ["SSL_CERT_FILE"] = certifi.where() load_dotenv(".env.local") # ============================================================================= # FASTAPI APP # ============================================================================= app = FastAPI(title="BMMI HR Voice AI", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/api/check-online") def check_online(): print("Success") return "Success-12" # ============================================================================= # CUSTOM AGENT # ============================================================================= class MyAgent(Agent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.conversation_history: list[tuple[float, str, str]] = [] self.sound_enabled = True # Track sound state def log_transcription(self, timestamp: float, role: str, text: str): """Keep local conversation record.""" self.conversation_history.append((timestamp, role, text)) self.conversation_history.sort(key=lambda x: x[0]) logging.info(self.conversation_history) class ConditionalTTS(tts.TTS): """ A TTS wrapper that only synthesizes audio if the agent's sound_enabled flag is True. """ def __init__(self, agent: MyAgent, tts_service: tts.TTS): super().__init__( capabilities=tts_service.capabilities, sample_rate=tts_service.sample_rate, num_channels=tts_service.num_channels, ) self._agent = agent self._tts = tts_service async def synthesize(self, text: str) -> tts.SynthesizeStream: if not self._agent.sound_enabled: # Return an empty stream if sound is disabled return tts.SynthesizeStream.from_text_stream(asyncio.sleep(10000)) # sleep forever return await self._tts.synthesize(text) @asynccontextmanager async def stream(self, **kwargs) -> AsyncIterator[tts.SynthesizeStream]: if not self._agent.sound_enabled: # sleep forever to not process anything yield tts.SynthesizeStream.from_text_stream(asyncio.sleep(10000)) return async with self._tts.stream(**kwargs) as stream: yield stream class LoggingTTS(tts.TTS): """ A TTS wrapper that logs the text being synthesized. """ def __init__(self, tts_service: tts.TTS): super().__init__( capabilities=tts_service.capabilities, sample_rate=tts_service.sample_rate, num_channels=tts_service.num_channels, ) self._tts = tts_service async def synthesize(self, text: str) -> tts.SynthesizeStream: logging.info(f"🎤 TTS synthesizing text: '{text}'") return await self._tts.synthesize(text) @asynccontextmanager async def stream(self, **kwargs) -> AsyncIterator[tts.SynthesizeStream]: """ Log text chunks as they are pushed to the TTS stream. """ async with self._tts.stream(**kwargs) as stream: original_push_text = stream.push_text def log_and_push(text: str): logging.info(f"🎤 TTS streaming text chunk: '{text}'") original_push_text(text) stream.push_text = log_and_push yield stream # ============================================================================= # ENTRYPOINT # ============================================================================= async def entrypoint(ctx: JobContext): await ctx.connect() participant = await ctx.wait_for_participant() logging.info(f"👤 Participant connected: {participant.identity}") # -- Setup Agent + LLM + TTS + STT -- agent = MyAgent( instructions="You are a helpful assistant that answers company-related questions clearly." ) last_spoken_info = {"text": "", "timestamp": 0.0} async def on_full_sentence(text: str): """Called when a full sentence is ready for TTS.""" nonlocal last_spoken_info timestamp = time.time() last_spoken_info = {"text": text, "timestamp": timestamp} agent.log_transcription(timestamp, "assistant", text) session = AgentSession( stt = elevenlabs.STT(), llm=groq.LLM(model="llama-3.1-8b-instant"), tts=LoggingTTS( tts_service=elevenlabs.TTS( voice_id="pNInz6obpgDQGcFmaJgB", model="eleven_turbo_v2" ) ), vad=silero.VAD.load(), ) # Disable mic until ready session.input.set_audio_enabled(False) @session.on("agent_state_changed") def on_agent_state_changed(ev: AgentStateChangedEvent): nonlocal last_spoken_info if ev.new_state == "speaking": logging.info("🗣️ Agent started speaking") # 🧠 NEW: Send final sentence payload 1 second after the agent starts speaking. if last_spoken_info["text"]: async def send_with_delay(): await asyncio.sleep(1) # Wait for 1 second try: payload = { "type": "final_sentence", "role": "assistant", "text": last_spoken_info["text"], "timestamp": last_spoken_info["timestamp"], } await ctx.room.local_participant.publish_data( json.dumps(payload).encode("utf-8"), reliable=True ) logging.info("✅ Sent final_sentence payload after 1s delay.") except Exception as e: logging.error(f"❌ Failed to send final sentence from on_agent_state_changed: {e}") asyncio.create_task(send_with_delay()) # ------------------------------------------------------------------------- # 🗣️ Log conversation items # ------------------------------------------------------------------------- @session.on("conversation_item_added") def on_conversation_item(event: ConversationItemAddedEvent): if not event.item.text_content: return if event.item.role=="user": agent.log_transcription(event.item.created_at, event.item.role, event.item.text_content) # ------------------------------------------------------------------------- # 📩 Handle text-based interruptions # ------------------------------------------------------------------------- @ctx.room.on("data_received") def on_data_received(data: rtc.DataPacket): try: payload = getattr(data, "payload", None) or getattr(data, "data", None) if not payload: return raw_text = payload.decode("utf-8", errors="ignore") if isinstance(payload, bytes) else str(payload) msg = None try: msg = json.loads(raw_text) # Only process messages that contain sound toggle if msg.get("hidden") is True and "User turned sound" in msg.get("message", ""): handle_sound_toggle(session, msg["message"]) except json.JSONDecodeError: pass msg_text = msg.get("message") if isinstance(msg, dict) else raw_text logging.info(f"💬 User Message: {msg_text}") except Exception as e: logging.error(f"Error handling data_received: {e}") # ------------------------------------------------------------------------- # 🎬 Start the agent session # ------------------------------------------------------------------------- await session.start( agent=agent, room=ctx.room, room_options=room_io.RoomOptions( audio_input=room_io.AudioInputOptions( noise_cancellation=lambda params: noise_cancellation.BVCTelephony() if params.participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP else noise_cancellation.BVC(), ), ),) # Re-enable audio input after connection async def fallback_reenable(): await asyncio.sleep(2) if not session.input.audio_enabled: session.input.set_audio_enabled(True) logging.warning("Audio re-enabled via fallback") asyncio.create_task(fallback_reenable()) @session.output.audio.on("playback_finished") def _on_playback_finished(ev: PlaybackFinishedEvent): if ev.interrupted: logging.info("Agent was interrupted during playback") interruption_payload = { "type": "interrupted", "message": "User interrupted the assistant.", } asyncio.create_task( ctx.room.local_participant.publish_data( json.dumps(interruption_payload).encode("utf-8"), reliable=True )) await session.say("Hello, how can I help you today?") # ============================================================================= # SERVER STARTUP # ============================================================================= async def start_api_server(port: int): logging.info(f"🚀 Starting FastAPI server on port {port}") config = uvicorn.Config(app=app, host="0.0.0.0", port=port, log_level="info") server = uvicorn.Server(config) await server.serve() def run_api_server(base_port): asyncio.run(start_api_server(base_port)) def handle_sound_toggle(session: AgentSession, message: str): if "on" in message.lower(): logging.info("🔊 User turned SOUND ON") session.output.set_audio_enabled(True) elif "off" in message.lower(): logging.info("🔇 User turned SOUND OFF") session.output.set_audio_enabled(False) def start_livekit_agent(port: int, agent_name: str): logging.info(f"🤖 Starting LiveKit agent on port {port}") options = WorkerOptions(entrypoint_fnc=entrypoint, port=port,agent_name="BMMI HR VoiceAI") cli.run_app(options) def main(): logging.info("Starting BMMI HR Voice AI system") base_port = 5029 api_process = multiprocessing.Process(target=run_api_server, args=(base_port,)) api_process.start() try: start_livekit_agent(base_port + 1, "agent_name") except KeyboardInterrupt: logging.info("🛑 Shutdown signal received") finally: api_process.terminate() api_process.join() logging.info("✅ System shutdown complete") if name == "__main__": load_dotenv() main()..........can you tell me if when output audio enable is made to false is the tts node processing?
t
Thanks — from your code and setup, calling
session.output.set_audio_enabled(False)
will prevent the agent from invoking the TTS pipeline (it does not simply mute playback). A few practical points specific to your code and how to confirm: 1. In-flight speech: If speech was already synthesizing when you flip sound off, synthesis already started may finish unless you interrupt it. Call
session.interrupt()
right after disabling audio to cancel any in-progress TTS. 2. Your Custom/Logging TTS wrappers: Your
LoggingTTS
wraps the real TTS and logs when synthesis happens. If you still see those log lines after calling
session.output.set_audio_enabled(False)
, that means TTS was invoked (either in-flight or another path). If those logs stop, TTS is not being called. 3. Your ConditionalTTS: Returning a long-sleeping stream prevents playback but still can let upstream code attempt to call
synthesize
unless you short-circuit earlier. Prefer returning an immediately-empty stream or raising to avoid starting the underlying TTS: ◦ Instead of
asyncio.sleep(10000)
, return an empty
SynthesizeStream
or skip calling the underlying
self._tts.synthesize(...)
entirely when
sound_enabled
is False. ◦ Or, before any call into TTS, check
session.output.audio_enabled
and skip synthesize entirely. 1. Quick verification steps: ◦ Toggle sound off and watch your
🎤 TTS synthesizing text:
logs from
LoggingTTS
. No logs → TTS not invoked. ◦ Observe TTS metrics/events (TTSMetrics) — they should stop. ◦ Confirm no new audio tracks are published after disabling audio. ◦ If you suspect in-flight tasks, call
session.interrupt()
and watch that
playback_finished
with
ev.interrupted
fires. If you want, I can: • Suggest a minimal change to
ConditionalTTS
that never calls the wrapped TTS when sound is off, or • Provide the exact log checks/placement to prove TTS is not running in your current runs. Which would you prefer?
s
is there other way to check which all are used on livekit dashboard or something like the cost
t
You can verify usage/costs and TTS activity using these options: 1) LiveKit metrics / dashboard • Check the LiveKit dashboard’s Metrics/Usage sections to see published audio tracks, TTS-related events, and bandwidth/compute usage. Look for TTSMetrics or audio-publish counts to confirm synthesis activity. • Check the Usage/Billing view for cost summaries tied to media processing. 2) Runtime logging & events (most reliable) • Use your existing LoggingTTS wrapper: if you stop seeing the “🎤 TTS synthesizing text” log after disabling audio, TTS isn’t being invoked. • Emit or collect TTSMetrics events server-side and confirm they stop after toggling audio off. • Log
session.output.audio_enabled
changes and call
session.interrupt()
immediately after disabling to cancel in-flight synthesis. 3) Inspect published tracks and player events • Verify no new audio tracks are published after audio is disabled. • Watch
playback_finished
events and TTS-related event traces to confirm synths stopped. 4) Short-term billing check • Correlate timestamps of suspected TTS calls with the dashboard usage records or TTSMetrics to see incurred processing. If you want, I can: • Give the exact log-check snippet to add to your code (Python) to detect TTSMetrics and synthesis calls, or • Point out exactly where to look in the LiveKit dashboard if you tell me which console view you see (Metrics or Usage). Which would you prefer?
s
from future import annotations import json import asyncio from contextlib import asynccontextmanager from typing import AsyncIterator import uvicorn import logging import os import certifi import time import multiprocessing from livekit.plugins import deepgram, groq, elevenlabs from dotenv import load_dotenv from custom_deepgram_tts import CustomDeepgramTTS from livekit.agents.voice import UserStateChangedEvent, AgentStateChangedEvent from livekit.agents.voice.io import PlaybackFinishedEvent from livekit import api, rtc from livekit.agents import ( tts, Agent, AgentSession, JobContext, llm, voice, WorkerOptions, cli, room_io, RunContext, RoomInputOptions, RoomOutputOptions, ) from livekit.plugins import silero, noise_cancellation from livekit.agents import metrics, MetricsCollectedEvent, ConversationItemAddedEvent from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware # Set SSL_CERT_FILE to use certifi's certificate bundle os.environ["SSL_CERT_FILE"] = certifi.where() load_dotenv(".env.local") # ============================================================================= # FASTAPI APP # ============================================================================= app = FastAPI(title="BMMI HR Voice AI", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/api/check-online") def check_online(): print("Success") return "Success-12" # ============================================================================= # CUSTOM AGENT # ============================================================================= class MyAgent(Agent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.conversation_history: list[tuple[float, str, str]] = [] self.sound_enabled = True # Track sound state def log_transcription(self, timestamp: float, role: str, text: str): """Keep local conversation record.""" self.conversation_history.append((timestamp, role, text)) self.conversation_history.sort(key=lambda x: x[0]) logging.info(self.conversation_history) class LoggingTTS(tts.TTS): """ A TTS wrapper that logs the text being synthesized. """ def __init__(self, tts_service: tts.TTS): super().__init__( capabilities=tts_service.capabilities, sample_rate=tts_service.sample_rate, num_channels=tts_service.num_channels, ) self._tts = tts_service async def synthesize(self, text: str) -> tts.SynthesizeStream: logging.info(f"🎤 TTS synthesizing text: '{text}'") return await self._tts.synthesize(text) @asynccontextmanager async def stream(self, **kwargs) -> AsyncIterator[tts.SynthesizeStream]: """ Log text chunks as they are pushed to the TTS stream. """ async with self._tts.stream(**kwargs) as stream: original_push_text = stream.push_text def log_and_push(text: str): logging.info(f"🎤 TTS streaming text chunk: '{text}'") original_push_text(text) stream.push_text = log_and_push yield stream # ============================================================================= # ENTRYPOINT # ============================================================================= async def entrypoint(ctx: JobContext): await ctx.connect() participant = await ctx.wait_for_participant() logging.info(f"👤 Participant connected: {participant.identity}") # -- Setup Agent + LLM + TTS + STT -- agent = MyAgent( instructions="You are a helpful assistant that answers company-related questions clearly." ) last_spoken_info = {"text": "", "timestamp": 0.0} async def on_full_sentence(text: str): """Called when a full sentence is ready for TTS.""" nonlocal last_spoken_info timestamp = time.time() last_spoken_info = {"text": text, "timestamp": timestamp} agent.log_transcription(timestamp, "assistant", text) session = AgentSession( stt = elevenlabs.STT(), llm=groq.LLM(model="llama-3.1-8b-instant"), tts=LoggingTTS( tts_service=elevenlabs.TTS( voice_id="pNInz6obpgDQGcFmaJgB", model="eleven_turbo_v2" ) ), vad=silero.VAD.load(), ) # Disable mic until ready session.input.set_audio_enabled(False) @session.on("agent_state_changed") def on_agent_state_changed(ev: AgentStateChangedEvent): nonlocal last_spoken_info if ev.new_state == "speaking": logging.info("🗣️ Agent started speaking") # 🧠 NEW: Send final sentence payload 1 second after the agent starts speaking. if last_spoken_info["text"]: async def send_with_delay(): await asyncio.sleep(1) # Wait for 1 second try: payload = { "type": "final_sentence", "role": "assistant", "text": last_spoken_info["text"], "timestamp": last_spoken_info["timestamp"], } await ctx.room.local_participant.publish_data( json.dumps(payload).encode("utf-8"), reliable=True ) logging.info("✅ Sent final_sentence payload after 1s delay.") except Exception as e: logging.error(f"❌ Failed to send final sentence from on_agent_state_changed: {e}") asyncio.create_task(send_with_delay()) # ------------------------------------------------------------------------- # 🗣️ Log conversation items # ------------------------------------------------------------------------- @session.on("conversation_item_added") def on_conversation_item(event: ConversationItemAddedEvent): if not event.item.text_content: return if event.item.role=="user": agent.log_transcription(event.item.created_at, event.item.role, event.item.text_content) # ------------------------------------------------------------------------- # 📩 Handle text-based interruptions # ------------------------------------------------------------------------- @ctx.room.on("data_received") def on_data_received(data: rtc.DataPacket): try: payload = getattr(data, "payload", None) or getattr(data, "data", None) if not payload: return raw_text = payload.decode("utf-8", errors="ignore") if isinstance(payload, bytes) else str(payload) msg = None try: msg = json.loads(raw_text) # Only process messages that contain sound toggle if msg.get("hidden") is True and "User turned sound" in msg.get("message", ""): handle_sound_toggle(session, msg["message"]) except json.JSONDecodeError: pass msg_text = msg.get("message") if isinstance(msg, dict) else raw_text logging.info(f"💬 User Message: {msg_text}") except Exception as e: logging.error(f"Error handling data_received: {e}") # ------------------------------------------------------------------------- # 🎬 Start the agent session # ------------------------------------------------------------------------- await session.start( agent=agent, room=ctx.room, room_options=room_io.RoomOptions( audio_input=room_io.AudioInputOptions( noise_cancellation=noise_cancellation.BVC(), ), ), ) # Re-enable audio input after connection async def fallback_reenable(): await asyncio.sleep(2) if not session.input.audio_enabled: session.input.set_audio_enabled(True) logging.warning("Audio re-enabled via fallback") asyncio.create_task(fallback_reenable()) @session.output.audio.on("playback_finished") def _on_playback_finished(ev: PlaybackFinishedEvent): if ev.interrupted: logging.info("Agent was interrupted during playback") interruption_payload = { "type": "interrupted", "message": "User interrupted the assistant.", } asyncio.create_task( ctx.room.local_participant.publish_data( json.dumps(interruption_payload).encode("utf-8"), reliable=True )) await session.say("Hello, how can I help you today?") # ============================================================================= # SERVER STARTUP # ============================================================================= async def start_api_server(port: int): logging.info(f"🚀 Starting FastAPI server on port {port}") config = uvicorn.Config(app=app, host="0.0.0.0", port=port, log_level="info") server = uvicorn.Server(config) await server.serve() def run_api_server(base_port): asyncio.run(start_api_server(base_port)) def handle_sound_toggle(session: AgentSession, message: str): if "on" in message.lower(): logging.info("🔊 User turned SOUND ON") session.output.set_audio_enabled(True) elif "off" in message.lower(): logging.info("🔇 User turned SOUND OFF") session.output.set_audio_enabled(False) def start_livekit_agent(port: int, agent_name: str): logging.info(f"🤖 Starting LiveKit agent on port {port}") options = WorkerOptions(entrypoint_fnc=entrypoint, port=port,agent_name="BMMI HR VoiceAI") cli.run_app(options) def main(): logging.info("Starting BMMI HR Voice AI system") base_port = 5029 api_process = multiprocessing.Process(target=run_api_server, args=(base_port,)) api_process.start() try: start_livekit_agent(base_port + 1, "agent_name") except KeyboardInterrupt: logging.info("🛑 Shutdown signal received") finally: api_process.terminate() api_process.join() logging.info("✅ System shutdown complete") if name == "__main__": load_dotenv() main()........... 101326.364 ERROR livekit task exception: <Task finished name='Task-84' coro=<AudioStream._run() done, defined at C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\… exception=AttributeError("'AudioStream' object has no attribute '_processor'")> Traceback (most recent call last): File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\rtc\audio_stream.py", line 276, in _run if self._processor is not None and self._processor.enabled: AttributeError: 'AudioStream' object has no attribute '_processor'
t
Thanks — I reviewed your code and the error. Short answers first, then targeted action items. Short answers • Calling
session.output.set_audio_enabled(False)
should stop TTS synthesis entirely (not just mute). To prove that in your app, rely on your LoggingTTS logs and TTS metrics: if the "🎤 TTS synthesizing text" log stops after you toggle off, TTS isn't being invoked. • Your current LoggingTTS will show whether TTS is being called. If you still see those logs after disabling audio, synthesis is starting (likely in-flight or from another path). • The AttributeError in your traceback (AudioStream has no attribute '_processor') indicates an SDK/runtime mismatch or unexpected internal state — that can allow stray audio-processing behavior. Fixing that will help ensure audio toggles behave predictably. What to check / do next (minimal steps) 1. Immediately cancel in-flight synthesis when toggling off ◦ After
session.output.set_audio_enabled(False)
call
session.interrupt()
to stop any currently-synthesizing speech. 1. Make LoggingTTS definitive (stop calling underlying TTS when sound off) ◦ Update LoggingTTS.synthesize to short-circuit when
session.output.audio_enabled
(or your agent flag) is False and return an immediately-empty stream instead of calling the wrapped TTS. Example idea: ▪︎ If sound is off: log "skipping synth" and return an empty
SynthesizeStream
(or raise/return a no-op stream). ▪︎ This prevents any wrapped TTS from being invoked by mistake. 1. Verify via logs & metrics ◦ Toggle sound OFF and watch for: ▪︎ No "🎤 TTS synthesizing text" logs. ▪︎ No TTSMetrics events after the toggle. ▪︎ No new audio tracks published. ◦ If logs still show synthesis, capture the exact log lines/timestamps. 1. Fix the AudioStream AttributeError ◦ That traceback suggests the installed livekit package version and your runtime are inconsistent (internal attribute missing). Steps: ▪︎ Confirm your livekit and livekit-agents package versions are compatible. ▪︎ Upgrade/reinstall packages (pip install --upgrade livekit livekit-agents) or pin to recommended versions. ▪︎ Restart the worker after reinstalling. ◦ Once that error is gone, re-test toggling audio — the SDK will behave more reliably. 1. Check for other TTS paths ◦ Search for other
say()
, direct
elevenlabs.TTS
calls, BackgroundAudioPlayer, or any code that calls your TTS plugin directly outside the AgentSession pipeline. Ensure those paths also respect the audio flag or are gated by your
sound_enabled
. If you want, I can: • Provide the exact small code edits for LoggingTTS/Conditional short-circuiting (paste target function and I’ll return the snippet), and • Suggest the exact pip commands and version pairs to try for fixing the AudioStream error. Which would you like me to send next — the LoggingTTS short-circuit snippet or the package/version commands?
s
101326.364 ERROR livekit task exception: <Task finished name='Task-84' coro=<AudioStream._run() done, defined at C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\… exception=AttributeError("'AudioStream' object has no attribute '_processor'")> Traceback (most recent call last): File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\rtc\audio_stream.py", line 276, in _run if self._processor is not None and self._processor.enabled: AttributeError: 'AudioStream' object has no attribute '_processor'....why i am i getting this error?
t
Thanks — that traceback points to an internal attribute access on the LiveKit SDK's AudioStream object. Short answer: it's caused by a mismatch between the SDK code you have and the runtime expectations (an internal attribute
_processor
is missing), or by an object being used after a partial/failed initialization. Here’s how to troubleshoot and fix it quickly. 1) Immediate cause • The code in livekit/rtc/audio_stream.py expects
self._processor
to exist, but the AudioStream instance doesn't have that attribute when
_run()
executes. That means either the AudioStream constructor didn't set
_processor
(version mismatch / bug) or the object was mutated/partially cleaned up before
_run()
finished. 2) Quick checks • Confirm you’re running compatible package versions for livekit and livekit-agents: ◦ Run: pip show livekit livekit-agents ◦ Note the versions and share them if you want help mapping compatibility. • Check whether the AudioStream object is being stopped/closed while
_run()
is still running (race condition). Look for early cleanup or exceptions during initialization in preceding logs. 3) Repro & fix steps • Upgrade/reinstall to the latest compatible packages: ◦ pip install --upgrade livekit livekit-agents ◦ Or pin to a known-compatible pair if you have version constraints. • Restart your worker after upgrading. • If the error persists, capture the full traceback and the versions from pip show. 4) Hardening workaround in your app (temporary) • Surround session start/stop with safe try/except or guard checks so a partially-initialized stream doesn't run: ◦ Ensure you don't call session.input.set_audio_enabled / session.output toggles before session.start completes. ◦ Add logging around session lifecycle events to see if
_run()
starts after a close. 5) If this is a regression • If you upgraded recently and this started appearing, pin to the previous working versions you had and test. If you share the output of: • pip show livekit • pip show livekit-agents • the full, untruncated traceback (a few lines before and after), I’ll point to the exact package pair or a code change to resolve this. Which would you like to send?
s
(venv) C:\Users\AnmyJosy\Desktop\full_transcript>py -3.10 -m pip show livekit livekit-agents Name: livekit Version: 1.0.21 Summary: Python Real-time SDK for LiveKit Home-page: https://github.com/livekit/python-sdks Author: Author-email: License: Apache-2.0 Location: c:\users\anmyjosy\appdata\local\programs\python\python310\lib\site-packages Requires: aiofiles, numpy, protobuf, types-protobuf Required-by: livekit-agents, livekit-plugins-groq, livekit-plugins-noise-cancellation --- Name: livekit-agents Version: 1.3.6 Summary: A powerful framework for building realtime voice AI agents Home-page: Author: Author-email: LiveKit <hello@livekit.io> License: Location: c:\users\anmyjosy\appdata\local\programs\python\python310\lib\site-packages Requires: aiofiles, aiohttp, av, certifi, click, colorama, docstring-parser, eval-type-backport, livekit, livekit-api, livekit-blingfire, livekit-protocol, nest-asyncio, numpy, openai, opentelemetry-api, opentelemetry-exporter-otlp, opentelemetry-sdk, prometheus-client, protobuf, psutil, pydantic, pyjwt, sounddevice, typer, types-protobuf, typing-extensions, watchfiles Required-by: livekit-plugins-bey, livekit-plugins-deepgram, livekit-plugins-elevenlabs, livekit-plugins-groq, livekit-plugins-openai, livekit-plugins-silero, livekit-plugins-turn-detector
t
Thanks — from the versions you shared, the most likely cause is a version mismatch between
livekit
(1.0.21) and
livekit-agents
(1.3.6). The Agents runtime expects an internal attribute on AudioStream that isn't present in the installed
livekit
package, causing the AttributeError. What to do next (minimal, safe steps) 1. Upgrade both packages so they match expected compatibility: • In your virtualenv run: pip install --upgrade livekit livekit-agents 1. Restart your worker after upgrading. 2. Verify installed versions: py -3.10 -m pip show livekit livekit-agents Expected result: no AttributeError from AudioStream after restart. If the error persists, capture and share: • the full, untruncated traceback, • the output of the pip show command after upgrade. I can then suggest a specific version pair to pin or a workaround. Which would you like — I can provide the exact pip commands to pin versions or a small defensive code check to avoid the crash.
s
i was trying to do the same thing with input audio so to reduce the cost .....so i dont want deepgram to work on starting is that possible?
t
You can prevent Deepgram (or any STT) from running until you explicitly enable it. Two safe patterns: 1) Start session with input audio disabled, then enable when user turns mic on • At session start set input disabled:
Copy code
await session.start(
  agent=agent,
  room=ctx.room,
  room_options=room_io.RoomOptions(
    audio_input=room_io.AudioInputOptions(...),
  ),
)
# ensure STT won't run until explicitly enabled
session.input.set_audio_enabled(False)
• When user clicks mic/sound ON:
Copy code
session.input.set_audio_enabled(True)
Behavior: while
session.input.audio_enabled
is False the agent will not publish/process microphone audio and the STT plugin (Deepgram) will not be invoked — avoiding STT processing costs until you enable it. 2) Lazy-initialize the STT plugin (explicit control) • Don’t attach Deepgram at AgentSession construction. Create the session without STT or with a no-op STT, and swap in Deepgram only when needed:
Copy code
# create session without real STT
session = AgentSession(stt=NoopSTT(), llm=..., tts=..., ...)
# later, when enabling mic:
session.stt = deepgram.Deepgram(...)   # attach real STT
Or implement a wrapper STT that short-circuits when
session.input.audio_enabled
is False so no network/costly calls occur. Notes & recommendations • If you toggle input off while speech is already in flight, call
session.interrupt()
to cancel in-progress processing. • Use logging/metrics to verify Deepgram requests stop after disabling input (no STTMetrics or Deepgram calls). • Pattern 1 is simplest; Pattern 2 gives the most control if you need to fully avoid any STT client initialization or long-lived connections. Tell me which approach you prefer (simple toggle vs lazy-init) and I’ll provide the exact code snippet for your current Python setup.
s
from future import annotations import json import asyncio from contextlib import asynccontextmanager from typing import AsyncIterator import uvicorn import logging import os import certifi import time import multiprocessing from livekit.plugins import deepgram, groq, elevenlabs from dotenv import load_dotenv from custom_deepgram_tts import CustomDeepgramTTS from livekit.agents.voice import UserStateChangedEvent, AgentStateChangedEvent from livekit.agents.voice.io import PlaybackFinishedEvent from livekit import api, rtc from livekit.agents import ( tts, Agent, AgentSession, JobContext, llm, voice, WorkerOptions, cli, room_io, RunContext, RoomInputOptions, RoomOutputOptions, ) from livekit.plugins import silero, noise_cancellation from livekit.agents import metrics, MetricsCollectedEvent, ConversationItemAddedEvent from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware # Set SSL_CERT_FILE to use certifi's certificate bundle os.environ["SSL_CERT_FILE"] = certifi.where() load_dotenv(".env.local") # ============================================================================= # FASTAPI APP # ============================================================================= app = FastAPI(title="BMMI HR Voice AI", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/api/check-online") def check_online(): print("Success") return "Success-12" # ============================================================================= # CUSTOM AGENT # ============================================================================= class MyAgent(Agent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.conversation_history: list[tuple[float, str, str]] = [] self.sound_enabled = True # Track sound state def log_transcription(self, timestamp: float, role: str, text: str): """Keep local conversation record.""" self.conversation_history.append((timestamp, role, text)) self.conversation_history.sort(key=lambda x: x[0]) logging.info(self.conversation_history) class LoggingTTS(tts.TTS): """ A TTS wrapper that logs the text being synthesized. """ def __init__(self, tts_service: tts.TTS): super().__init__( capabilities=tts_service.capabilities, sample_rate=tts_service.sample_rate, num_channels=tts_service.num_channels, ) self._tts = tts_service async def synthesize(self, text: str) -> tts.SynthesizeStream: logging.info(f"🎤 TTS synthesizing text: '{text}'") return await self._tts.synthesize(text) @asynccontextmanager async def stream(self, **kwargs) -> AsyncIterator[tts.SynthesizeStream]: """ Log text chunks as they are pushed to the TTS stream. """ async with self._tts.stream(**kwargs) as stream: original_push_text = stream.push_text def log_and_push(text: str): logging.info(f"🎤 TTS streaming text chunk: '{text}'") original_push_text(text) stream.push_text = log_and_push yield stream # ============================================================================= # ENTRYPOINT # ============================================================================= async def entrypoint(ctx: JobContext): await ctx.connect() participant = await ctx.wait_for_participant() logging.info(f"👤 Participant connected: {participant.identity}") # -- Setup Agent + LLM + TTS + STT -- agent = MyAgent( instructions="You are a helpful assistant that answers company-related questions clearly." ) last_spoken_info = {"text": "", "timestamp": 0.0} async def on_full_sentence(text: str): """Called when a full sentence is ready for TTS.""" nonlocal last_spoken_info timestamp = time.time() last_spoken_info = {"text": text, "timestamp": timestamp} agent.log_transcription(timestamp, "assistant", text) session = AgentSession( stt = deepgram.STT( model="nova-2-general", ), llm=groq.LLM(model="llama-3.1-8b-instant"), tts=LoggingTTS( tts_service=elevenlabs.TTS( voice_id="pNInz6obpgDQGcFmaJgB", model="eleven_turbo_v2" ) ), vad=silero.VAD.load(), ) @session.on("agent_state_changed") def on_agent_state_changed(ev: AgentStateChangedEvent): nonlocal last_spoken_info if ev.new_state == "speaking": logging.info("🗣️ Agent started speaking") # 🧠 NEW: Send final sentence payload 1 second after the agent starts speaking. if last_spoken_info["text"]: async def send_with_delay(): await asyncio.sleep(1) # Wait for 1 second try: payload = { "type": "final_sentence", "role": "assistant", "text": last_spoken_info["text"], "timestamp": last_spoken_info["timestamp"], } await ctx.room.local_participant.publish_data( json.dumps(payload).encode("utf-8"), reliable=True ) logging.info("✅ Sent final_sentence payload after 1s delay.") except Exception as e: logging.error(f"❌ Failed to send final sentence from on_agent_state_changed: {e}") asyncio.create_task(send_with_delay()) # ------------------------------------------------------------------------- # 🗣️ Log conversation items # ------------------------------------------------------------------------- @session.on("conversation_item_added") def on_conversation_item(event: ConversationItemAddedEvent): if not event.item.text_content: return if event.item.role=="user": agent.log_transcription(event.item.created_at, event.item.role, event.item.text_content) # ------------------------------------------------------------------------- # 📩 Handle text-based interruptions # ------------------------------------------------------------------------- @ctx.room.local_participant.register_rpc_method("sound_control") async def handle_sound_control(data: rtc.RpcInvocationData): try: payload = json.loads(data.payload) sound_enabled = payload.get("soundEnabled") if sound_enabled is True: logging.info("🔊 User turned SOUND ON via RPC") session.output.set_audio_enabled(True) elif sound_enabled is False: logging.info("🔇 User turned SOUND OFF via RPC") session.output.set_audio_enabled(False) except Exception as e: logging.error(f"Error handling sound_control RPC: {e}") @ctx.room.local_participant.register_rpc_method("mic_control") async def handle_mic_control(data: rtc.RpcInvocationData): try: payload = json.loads(data.payload) mic_enabled = payload.get("micEnabled") if mic_enabled is True: logging.info("🎤 User turned MIC ON via RPC") session.input.set_audio_enabled(True) elif mic_enabled is False: logging.info("🔇 User turned MIC OFF via RPC") session.input.set_audio_enabled(False) except Exception as e: logging.error(f"Error handling mic_control RPC: {e}") @ctx.room.on("data_received") def on_data_received(data: rtc.DataPacket): try: payload = getattr(data, "payload", None) or getattr(data, "data", None) if not payload: return raw_text = payload.decode("utf-8", errors="ignore") if isinstance(payload, bytes) else str(payload) logging.info(f"💬 User Message: {raw_text}") except Exception as e: logging.error(f"Error handling data_received: {e}") # ------------------------------------------------------------------------- # 🎬 Start the agent session # ------------------------------------------------------------------------- await session.start( agent=agent, room=ctx.room, room_options=room_io.RoomOptions( audio_input=room_io.AudioInputOptions(...), ), ) session.input.set_audio_enabled(False) @session.output.audio.on("playback_finished") def _on_playback_finished(ev: PlaybackFinishedEvent): if ev.interrupted: logging.info("Agent was interrupted during playback") interruption_payload = { "type": "interrupted", "message": "User interrupted the assistant.", } asyncio.create_task( ctx.room.local_participant.publish_data( json.dumps(interruption_payload).encode("utf-8"), reliable=True )) # ============================================================================= # SERVER STARTUP # ============================================================================= async def start_api_server(port: int): logging.info(f"🚀 Starting FastAPI server on port {port}") config = uvicorn.Config(app=app, host="0.0.0.0", port=port, log_level="info") server = uvicorn.Server(config) await server.serve() def run_api_server(base_port): asyncio.run(start_api_server(base_port)) def start_livekit_agent(port: int, agent_name: str): logging.info(f"🤖 Starting LiveKit agent on port {port}") options = WorkerOptions(entrypoint_fnc=entrypoint, port=port,agent_name="BMMI HR VoiceAI") cli.run_app(options) def main(): logging.info("Starting BMMI HR Voice AI system") base_port = 5029 api_process = multiprocessing.Process(target=run_api_server, args=(base_port,)) api_process.start() try: start_livekit_agent(base_port + 1, "agent_name") except KeyboardInterrupt: logging.info("🛑 Shutdown signal received") finally: api_process.terminate() api_process.join() logging.info("✅ System shutdown complete") if name == "__main__": load_dotenv() main()......is this correct
t
You're very close — overall approach is correct. A few concrete fixes and confirmations so your toggles truly stop STT/TTS and avoid errors. 1) STT (Deepgram) off until enabled • Your pattern of creating session with
stt=deepgram.STT(...)
then immediately calling
session.input.set_audio_enabled(False)
still initializes Deepgram (connection created). To avoid any STT work/cost until user enables mic, use lazy init or a no-op wrapper: ◦ Simple: construct session with a Noop STT that does nothing, then assign real Deepgram when mic is enabled:
Copy code
session = AgentSession(
      stt=NoopSTT(),  # no network calls
      llm=...,
      tts=LoggingTTS(...),
      vad=...
    )

    # when user enables mic
    session.stt = deepgram.STT(model="nova-2-general")
    session.input.set_audio_enabled(True)
• Alternative: in your
handle_mic_control
before calling
set_audio_enabled(True)
create and attach
session.stt = deepgram.STT(...)
so Deepgram is only instantiated when needed. 2) Ensure TTS is never invoked when sound off • You already toggle
session.output.set_audio_enabled(False)
. To be defensive, short-circuit TTS at wrapper level so the underlying TTS client is never called: ◦ In your
LoggingTTS.synthesize
check the session/agent flag and return an immediate no-op stream instead of calling
self._tts.synthesize(...)
. That prevents any network/costly TTS calls. ◦ Example idea:
Copy code
async def synthesize(self, text: str) -> tts.SynthesizeStream:
        if not agent.sound_enabled or not session.output.audio_enabled:
            <http://logging.info|logging.info>("Skipping TTS synth because sound is disabled")
            return tts.SynthesizeStream.empty()  # or other immediate no-op
        <http://logging.info|logging.info>(f"🎤 TTS synthesizing text: '{text}'")
        return await self._tts.synthesize(text)
• Also call
session.interrupt()
immediately after disabling audio to cancel in-progress synthesis. 3) Address the AudioStream AttributeError • Cause:
livekit
(1.0.21) and
livekit-agents
(1.3.6) are out of sync; Agents expects an internal
_processor
attribute not present in your livekit version. • Fix: ◦ Upgrade so packages match: pip install --upgrade livekit livekit-agents • Restart your agent process. • If upgrade introduces other issues, pin a compatible pair; if you want I can recommend exact versions after you upgrade and re-check. • Also avoid toggling audio before session.start completes — that can cause lifecycle races that surface similar internal errors. 4) Minor correctness checks in your code • You call
session.input.set_audio_enabled(False)
right after
await session.start(...)
— ok, but prefer to: ◦ Start session with
room_options
and then set input disabled before any participant audio reaches the pipeline. • Your
LoggingTTS.stream
uses
original_push_text
wrapper correctly to log chunks — keep it. • Registering RPC handlers on
ctx.room.local_participant
is correct; ensure clients send the correct JSON payload shape. 5) How to verify no STT/TTS costs • Deepgram: no network calls or STTMetrics after mic disabled or before STT attached. • TTS: no "🎤 TTS synthesizing text" logs and no TTSMetrics after audio disabled; wrapped TTS short-circuit ensures no external calls. • Correlate timestamps with dashboard Usage/Metrics for final confirmation. If you want, I can: • Provide the exact
NoopSTT
implementation and
SynthesizeStream.empty()
pattern for your environment, or • Give the pip commands and a recommended compatible version pair to fix the AudioStream error. Which would you like next?
s
NameError: name 'NoopSTT' is not defined
t
Thanks — that NameError means you referenced
NoopSTT
but never defined it. Below is a minimal, safe
NoopSTT
implementation you can drop into your code and use when you want no STT activity until the mic is explicitly enabled. Paste this near your other class definitions:
Copy code
from contextlib import asynccontextmanager
from typing import AsyncIterator
from livekit.agents import tts, voice

class NoopSTT(voice.STT):
    """A no-op STT that accepts audio but never sends it to an external STT."""
    def __init__(self):
        super().__init__(capabilities=None)

    async def transcribe(self, audio_stream) -> AsyncIterator[voice.TranscriptionChunk]:
        # Yield nothing — acts as if there is no transcription
        if False:
            yield  # type: ignore

    @asynccontextmanager
    async def stream(self, **kwargs) -> AsyncIterator[voice.TranscriptionStream]:
        # Provide a stream object that has a no-op push_frame / close behavior.
        class _NoopStream:
            def push_frame(self, frame):
                pass
            async def close(self):
                pass
        yield _NoopStream()
How to use it • Construct the session without Deepgram: session = AgentSession(stt=NoopSTT(), llm=..., tts=..., vad=...) • When user enables mic, attach Deepgram and enable input: session.stt = deepgram.STT(model="nova-2-general") session.input.set_audio_enabled(True) • When user disables mic, disable input and optionally detach STT: session.input.set_audio_enabled(False) session.stt = NoopSTT() # frees Deepgram usage if you want to stop background connections Notes • Assigning
session.stt = deepgram.STT(...)
instantiates the plugin lazily, avoiding upfront STT costs. • If you need a more feature-complete Noop (e.g., exposing metadata or methods), tell me what STT methods you rely on and I’ll extend it. • After switching STT implementations, watch logs/metrics to confirm no Deepgram requests occur. Would you like a ready-to-paste variant that mirrors any specific stream API your code currently expects (e.g., attributes/method names returned by your existing STT)?
s
(venv) C:\Users\AnmyJosy\Desktop\full_transcript>py -3.10 agent.py dev DEBUGasyncioUsing proactor: IocpProactor 132138.035 DEBUG asyncio Using proactor: IocpProactor DEVlivekit.agentsWatching C:\Users\AnmyJosy\Desktop\full_transcript 132138.040 DEV livekit.agents Watching C:\Users\AnmyJosy\Desktop\full_transcript 132140.755 DEBUG asyncio Using proactor: IocpProactor 132140.761 INFO livekit.agents starting worker {"version": "1.3.6", "rtc-version": "1.0.22"} INFO: Started server process [11688] INFO: Waiting for application startup. INFO: Application startup complete. INFO: Uvicorn running on http://0.0.0.0:5029 (Press CTRL+C to quit) 132140.762 INFO livekit.agents HTTP server listening on :5030 132140.959 INFO livekit.agents registered worker {"agent_name": "BMMI HR VoiceAI", "id": "AW_iaJnHXmGYeFt", "url": "wss://part12-cy0qmp6y.livekit.cloud", "region": "India South", "protocol": 16} 132141.011 INFO livekit.agents received job request {"job_id": "AJ_VX28gK4D8348", "dispatch_id": "AD_6s5UBx8snLyu", "room": "room-peQg-B9Op", "room_id": "RM_pByhcuuHh8y6", "agent_name": "BMMI HR VoiceAI", "resuming": false, "enable_recording": false} 132141.042 DEBUG livekit.agents received assignment {"agent_name": "BMMI HR VoiceAI", "room_id": "RM_pByhcuuHh8y6", "room": "room-peQg-B9Op", "job_id": "AJ_VX28gK4D8348", "dispatch_id": "AD_6s5UBx8snLyu", "enable_recording": false} 132141.054 INFO livekit.agents initializing job runner {"tid": 25844} 132141.061 DEBUG asyncio Using proactor: IocpProactor 132141.064 INFO livekit.agents job runner initialized {"tid": 25844, "elapsed_time": 0.0} 132141.567 INFO root 👤 Participant connected: identity-ogZq 132141.724 DEBUG livekit.agents input stream detached {"participant": null, "source": "SOURCE_UNKNOWN", "accepted_sources": ["SOURCE_MICROPHONE"]} 132141.730 DEBUG livekit.agents http_session(): creating a new httpclient ctx 132141.732 DEBUG livekit.agents using audio io:
RoomIO
->
AgentSession
->
TranscriptSynchronizer
->
RoomIO
132141.734 DEBUG livekit.agents using transcript io:
AgentSession
->
TranscriptSynchronizer
->
RoomIO
132142.634 ERROR livekit.agents AgentSession is closing due to unrecoverable error Traceback (most recent call last): File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\agents\stt\stt.py", line 289, in _main_task return await self._run() File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\plugins\deepgram\stt.py", line 518, in _run ws = await self._connect_ws() File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\plugins\deepgram\stt.py", line 582, in _connect_ws ws = await asyncio.wait_for( File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\asyncio\tasks.py", line 445, in wait_for return fut.result() File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\aiohttp\client.py", line 1494, in send return self._coro.send(arg) File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\aiohttp\client.py", line 1093, in _ws_connect raise WSServerHandshakeError( aiohttp.client_exceptions.WSServerHandshakeError: 401, message='Invalid response status', url='wss://api.deepgram.com/v1/listen?model=nova-2-general&amp;punctuate=true&amp;smart_format=false&amp;no_delay=true&amp;interim_results =true&encoding=linear16&vad_events=true&sample_rate=16000&channels=1&endpointing=25&filler_words=true&profanity_filter=fal se&numerals=false&mip_opt_out=false&language=en-US' 132142.640 ERROR livekit.agents Error in _stt_task Traceback (most recent call last): File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\agents\stt\stt.py", line 408, in anext val = await self._event_aiter.__anext__() StopAsyncIteration During handling of the above exception, another exception occurred: Traceback (most recent call last): File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\agents\utils\log.py", line 16, in async_fn_logs return await fn(*args, **kwargs) File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\agents\voice\audio_recognition.py", line 660, in _stt_task async for ev in node: File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\agents\voice\agent.py", line 391, in stt_node async for event in stream: File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\agents\stt\stt.py", line 411, in anext raise exc # noqa: B904 File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\agents\stt\stt.py", line 289, in _main_task return await self._run() File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\plugins\deepgram\stt.py", line 518, in _run ws = await self._connect_ws() File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\livekit\plugins\deepgram\stt.py", line 582, in _connect_ws ws = await asyncio.wait_for( File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\asyncio\tasks.py", line 445, in wait_for return fut.result() File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\aiohttp\client.py", line 1494, in send return self._coro.send(arg) File "C:\Users\AnmyJosy\AppData\Local\Programs\Python\Python310\lib\site-packages\aiohttp\client.py", line 1093, in _ws_connect raise WSServerHandshakeError( aiohttp.client_exceptions.WSServerHandshakeError: 401, message='Invalid response status', url='wss://api.deepgram.com/v1/listen?model=nova-2-general&amp;punctuate=true&amp;smart_format=false&amp;no_delay=true&amp;interim_results =true&encoding=linear16&vad_events=true&sample_rate=16000&channels=1&endpointing=25&filler_words=true&profanity_filter=fal se&numerals=false&mip_opt_out=false&language=en-US' 132142.647 DEBUG livekit.agents input stream detached {"participant": "identity-ogZq", "source": "SOURCE_UNKNOWN", "accepted_sources": ["SOURCE_MICROPHONE"]} 132142.649 DEBUG livekit.agents session closed {"reason": "error", "error": "type='stt_error' timestamp=1766044302.6341448 label='livekit.plugins.deepgram.stt.STT' error=WSServerHandshakeError(RequestInfo(url=URL('wss://api.deepgram.com/v1/listen?model=nova-2-general&amp;punctuate=true&amp;sma rt_format=false&no_delay=true&interim_results=true&encoding=linear16&vad_events=true&sample_rate=16000&channels=1&endpoint ing=25&filler_words=true&profanity_filter=false&numerals=false&mip_opt_out=false&language=en-US'), method='GET', headers=<CIMultiDictProxy('Host': 'api.deepgram.com', 'Authorization': 'Token 742ba0d9e5b304fd83c35e90b2bd185e5dcdbfa41', 'Upgrade': 'websocket', 'Connection': 'Upgrade', 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': 'rpxF1u3c1KBoAo8YsnVKRw==', 'Accept': '*/*', 'Accept-Encoding': 'gzip, deflate', 'User-Agent': 'Python/3.10 aiohttp/3.13.2')>, real_url=URL('wss://api.deepgram.com/v1/listen?model=nova-2-general&amp;punctuate=true&amp;smart_format=false&amp;no_delay=true&amp;interi m_results=true&encoding=linear16&vad_events=true&sample_rate=16000&channels=1&endpointing=25&filler_words=true&profanity_f ilter=false&numerals=false&mip_opt_out=false&language=en-US')), (), status=401, message='Invalid response status', headers=<CIMultiDictProxy('Content-Type': 'application/json', 'dg-error': 'Invalid credentials.', 'Vary': 'origin, access-control-request-method, access-control-request-headers', 'Vary': 'accept-encoding', 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Expose-Headers': 'dg-model-name,dg-model-uuid,dg-char-count,dg-request-id,dg-project-id,dg-error', 'Content-Encoding': 'gzip', 'dg-request-id': '139bb117-ad0f-4355-97ae-466ec6475d14', 'Transfer-Encoding': 'chunked', 'Date': 'Thu, 18 Dec 2025 075143 GMT')>) recoverable=False"}..........but when i am starting its still connecting to deepgram
t
You're seeing Deepgram still attempt to connect because you constructed the session with
stt=deepgram.STT(...)
— that starts Deepgram's connection task even if you immediately call
session.input.set_audio_enabled(False)
. Use a Noop STT at startup and attach Deepgram only when the user enables the mic. Drop this minimal NoopSTT into your file (near your other class defs) and use it when creating the session:
Copy code
from contextlib import asynccontextmanager
from typing import AsyncIterator
from livekit.agents import voice

class NoopSTT(voice.STT):
    """No-op STT: never opens a network connection or yields transcription."""
    def __init__(self):
        super().__init__(capabilities=None)

    async def transcribe(self, audio_stream) -> AsyncIterator[voice.TranscriptionChunk]:
        if False:
            yield  # keep as an async generator but yield nothing

    @asynccontextmanager
    async def stream(self, **kwargs) -> AsyncIterator[voice.TranscriptionStream]:
        class _NoopStream:
            def push_frame(self, frame):
                pass
            async def close(self):
                pass
        yield _NoopStream()
Change session creation and mic toggle as follows:
Copy code
# create session without instantiating Deepgram
session = AgentSession(
    stt=NoopSTT(),
    llm=groq.LLM(...),
    tts=LoggingTTS(...),
    vad=silero.VAD.load(),
)

# when user enables mic (RPC handler)
session.stt = deepgram.STT(model="nova-2-general")   # instantiate Deepgram here
session.input.set_audio_enabled(True)

# when user disables mic
session.input.set_audio_enabled(False)
session.stt = NoopSTT()   # optionally detach Deepgram to stop background connection
Notes and next steps • Detaching by reassigning
session.stt = NoopSTT()
prevents Deepgram from reconnecting and avoids STT costs. • If you want to fully close any existing Deepgram connection before replacing it, call any cleanup API exposed by the plugin or restart the session task; reassigning will stop the agents pipeline from using the old STT, but the plugin may need internal cleanup. • Your logs show a 401 from Deepgram: fix your Deepgram API token (Invalid credentials). Until that token is valid, Deepgram will fail to connect even when you attach it. • Keep
session.input.set_audio_enabled(False)
to stop audio processing; using NoopSTT prevents initialization cost entirely. If you want, I can: • Provide a small helper that lazily constructs Deepgram only once and reuses it, or • Show how to add logging to confirm Deepgram connections are created/closed. Which would you prefer?
s
from future import annotations import json import asyncio from contextlib import asynccontextmanager from typing import AsyncIterator import uvicorn import logging import os import certifi import time import multiprocessing from livekit.plugins import deepgram, groq, elevenlabs from dotenv import load_dotenv from custom_deepgram_tts import CustomDeepgramTTS from livekit.agents.voice import UserStateChangedEvent, AgentStateChangedEvent from livekit.agents.voice.io import PlaybackFinishedEvent from livekit import api, rtc from livekit.agents import ( tts, stt, Agent, AgentSession, JobContext, llm, voice, WorkerOptions, cli, room_io, RunContext, RoomInputOptions, RoomOutputOptions, ) from livekit.plugins import silero, noise_cancellation from livekit.agents import metrics, MetricsCollectedEvent, ConversationItemAddedEvent from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware # Set SSL_CERT_FILE to use certifi's certificate bundle os.environ["SSL_CERT_FILE"] = certifi.where() load_dotenv(".env.local") # ============================================================================= # FASTAPI APP # ============================================================================= app = FastAPI(title="BMMI HR Voice AI", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/api/check-online") def check_online(): print("Success") return "Success-12" # ============================================================================= # CUSTOM AGENT # ============================================================================= class MyAgent(Agent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.conversation_history: list[tuple[float, str, str]] = [] self.sound_enabled = True # Track sound state def log_transcription(self, timestamp: float, role: str, text: str): """Keep local conversation record.""" self.conversation_history.append((timestamp, role, text)) self.conversation_history.sort(key=lambda x: x[0]) logging.info(self.conversation_history) from contextlib import asynccontextmanager from typing import AsyncIterator from livekit.agents import tts, voice from livekit.agents import stt class NoopSTT(stt.STT): """A no-op STT that accepts audio but never sends it to an external STT.""" def __init__(self): super().__init__(capabilities=None) async def _recognize_impl(self, buffer: rtc.AudioFrame) -> str: """A no-op recognize implementation.""" return "" async def transcribe(self, audio_stream) -> AsyncIterator[stt.TranscriptionChunk]: # Yield nothing — acts as if there is no transcription if False: yield # type: ignore @asynccontextmanager async def stream(self, **kwargs) -> AsyncIterator[stt.TranscriptionStream]: # Provide a stream object that has a no-op push_frame / close behavior. class _NoopStream: def push_frame(self, frame): pass async def close(self): pass yield _NoopStream() # ============================================================================= # ENTRYPOINT # ============================================================================= async def entrypoint(ctx: JobContext): await ctx.connect() participant = await ctx.wait_for_participant() logging.info(f"👤 Participant connected: {participant.identity}") # -- Setup Agent + LLM + TTS + STT -- agent = MyAgent( instructions="You are a helpful assistant that answers company-related questions clearly." ) last_spoken_info = {"text": "", "timestamp": 0.0} async def on_full_sentence(text: str): """Called when a full sentence is ready for TTS.""" nonlocal last_spoken_info timestamp = time.time() last_spoken_info = {"text": text, "timestamp": timestamp} agent.log_transcription(timestamp, "assistant", text) session = AgentSession( stt=deepgram.STT(model="nova-2-general"), llm=groq.LLM(model="llama-3.1-8b-instant"), tts=elevenlabs.TTS( voice_id="pNInz6obpgDQGcFmaJgB", model="eleven_turbo_v2" ), vad=silero.VAD.load(), ) # Disable mic until ready session.input.set_audio_enabled(False) @session.on("agent_state_changed") def on_agent_state_changed(ev: AgentStateChangedEvent): nonlocal last_spoken_info if ev.new_state == "speaking": logging.info("🗣️ Agent started speaking") # 🧠 NEW: Send final sentence payload 1 second after the agent starts speaking. if last_spoken_info["text"]: async def send_with_delay(): await asyncio.sleep(1) # Wait for 1 second try: payload = { "type": "final_sentence", "role": "assistant", "text": last_spoken_info["text"], "timestamp": last_spoken_info["timestamp"], } await ctx.room.local_participant.publish_data( json.dumps(payload).encode("utf-8"), reliable=True ) logging.info("✅ Sent final_sentence payload after 1s delay.") except Exception as e: logging.error(f"❌ Failed to send final sentence from on_agent_state_changed: {e}") asyncio.create_task(send_with_delay()) # ------------------------------------------------------------------------- # 🗣️ Log conversation items # ------------------------------------------------------------------------- @session.on("conversation_item_added") def on_conversation_item(event: ConversationItemAddedEvent): if not event.item.text_content: return if event.item.role=="user": agent.log_transcription(event.item.created_at, event.item.role, event.item.text_content) # ------------------------------------------------------------------------- # 📩 Handle text-based interruptions # ------------------------------------------------------------------------- @ctx.room.local_participant.register_rpc_method("sound_control") async def handle_sound_control(data: rtc.RpcInvocationData): try: payload = json.loads(data.payload) sound_enabled = payload.get("soundEnabled") if sound_enabled is True: logging.info("🔊 User turned SOUND ON via RPC") session.output.set_audio_enabled(True) elif sound_enabled is False: logging.info("🔇 User turned SOUND OFF via RPC") session.output.set_audio_enabled(False) except Exception as e: logging.error(f"Error handling sound_control RPC: {e}") @ctx.room.local_participant.register_rpc_method("mic_control") async def handle_mic_control(data: rtc.RpcInvocationData): try: payload = json.loads(data.payload) mic_enabled = payload.get("micEnabled") if mic_enabled is True: logging.info("🎤 User turned MIC ON via RPC") session.input.set_audio_enabled(True) elif mic_enabled is False: logging.info("🔇 User turned MIC OFF via RPC") session.input.set_audio_enabled(False) except Exception as e: logging.error(f"Error handling mic_control RPC: {e}") @ctx.room.on("data_received") def on_data_received(data: rtc.DataPacket): try: payload = getattr(data, "payload", None) or getattr(data, "data", None) if not payload: return raw_text = payload.decode("utf-8", errors="ignore") if isinstance(payload, bytes) else str(payload) logging.info(f"💬 User Message: {raw_text}") except Exception as e: logging.error(f"Error handling data_received: {e}") # ------------------------------------------------------------------------- # 🎬 Start the agent session # ------------------------------------------------------------------------- await session.start( agent=agent, room=ctx.room, room_options=room_io.RoomOptions( audio_input=room_io.AudioInputOptions(...), ), ) @session.output.audio.on("playback_finished") def _on_playback_finished(ev: PlaybackFinishedEvent): if ev.interrupted: logging.info("Agent was interrupted during playback") interruption_payload = { "type": "interrupted", "message": "User interrupted the assistant.", } asyncio.create_task( ctx.room.local_participant.publish_data( json.dumps(interruption_payload).encode("utf-8"), reliable=True )) await session.say("Hello, how can I help you today?") # ============================================================================= # SERVER STARTUP # ============================================================================= async def start_api_server(port: int): logging.info(f"🚀 Starting FastAPI server on port {port}") config = uvicorn.Config(app=app, host="0.0.0.0", port=port, log_level="info") server = uvicorn.Server(config) await server.serve() def run_api_server(base_port): asyncio.run(start_api_server(base_port)) def start_livekit_agent(port: int, agent_name: str): logging.info(f"🤖 Starting LiveKit agent on port {port}") options = WorkerOptions(entrypoint_fnc=entrypoint, port=port,agent_name="BMMI HR VoiceAI") cli.run_app(options) def main(): logging.info("Starting BMMI HR Voice AI system") base_port = 5029 api_process = multiprocessing.Process(target=run_api_server, args=(base_port,)) api_process.start() try: start_livekit_agent(base_port + 1, "agent_name") except KeyboardInterrupt: logging.info("🛑 Shutdown signal received") finally: api_process.terminate() api_process.join() logging.info("✅ System shutdown complete") if name == "__main__": load_dotenv() main()
t
You're very close — the remaining issue is that you still construct the session with
deepgram.STT(...)
, which starts Deepgram's connection task immediately. Use the Noop STT at startup, then attach Deepgram only when the user enables the mic. Suggested minimal changes (drop into your file): 1) Define NoopSTT (you already have a variant; use this one):
Copy code
from contextlib import asynccontextmanager
from typing import AsyncIterator
from livekit.agents import stt, voice, rtc

class NoopSTT(stt.STT):
    """No-op STT: never opens a network connection or yields transcription."""
    def __init__(self):
        super().__init__(capabilities=None)

    async def transcribe(self, audio_stream) -> AsyncIterator[stt.TranscriptionChunk]:
        if False:
            yield  # keep as async generator but yield nothing

    @asynccontextmanager
    async def stream(self, **kwargs) -> AsyncIterator[stt.TranscriptionStream]:
        class _NoopStream:
            def push_frame(self, frame: rtc.AudioFrame):
                pass
            async def close(self):
                pass
        yield _NoopStream()
2) Create the session with NoopSTT (avoid Deepgram starting):
Copy code
session = AgentSession(
    stt = NoopSTT(),
    llm = groq.LLM(model="llama-3.1-8b-instant"),
    tts = elevenlabs.TTS(...),
    vad = silero.VAD.load(),
)
3) Lazily attach Deepgram when user enables mic (RPC handler):
Copy code
@ctx.room.local_participant.register_rpc_method("mic_control")
async def handle_mic_control(data: rtc.RpcInvocationData):
    payload = json.loads(data.payload)
    mic_enabled = payload.get("micEnabled")
    if mic_enabled is True:
        <http://logging.info|logging.info>("🎤 User turned MIC ON via RPC")
        # instantiate Deepgram only when needed
        session.stt = deepgram.STT(model="nova-2-general")
        session.input.set_audio_enabled(True)
    elif mic_enabled is False:
        <http://logging.info|logging.info>("🔇 User turned MIC OFF via RPC")
        session.input.set_audio_enabled(False)
        # optionally detach Deepgram to stop any background connection
        session.stt = NoopSTT()
4) Fix Deepgram 401 during testing • Your logs show Deepgram returns 401 (Invalid credentials). Even when attaching lazily, an invalid API key will cause immediate failed connections. Make sure your Deepgram API token is valid in env before instantiating
deepgram.STT()
. 5) Optional: reuse a single Deepgram instance • If you prefer not to recreate the client repeatedly, create it on first enable and keep a reference to reuse instead of re-instantiating. 6) Verify • After these changes, start your agent and do not enable mic — Deepgram should not attempt any connection and you should not see Deepgram logs or 401 errors. • Enable mic and confirm Deepgram connects successfully (or shows a successful handshake). If you’d like, I can give: • a tiny reusable helper to lazily create/reuse Deepgram, or • the exact log lines to watch to confirm no Deepgram activity. Which do you prefer?
s
session.stt = deepgram.STT(model="nova-2-general") AttributeError: can't set attribute 'stt'
t
You're seeing that error because
AgentSession.stt
is read-only — you cannot assign a new STT instance to it after session creation. Two practical ways to get the lazy/conditional STT behavior you want: Option A — Conditional wrapper (recommended) • Create an STT wrapper that forwards to a real STT only when mic is enabled. This lets you construct the session once and avoid starting Deepgram until you flip the flag. Example (drop into your code; adapt names):
Copy code
from contextlib import asynccontextmanager
from typing import AsyncIterator
from livekit.agents import stt, voice, rtc
import logging

class ConditionalSTT(stt.STT):
    def __init__(self, delegate_factory):
        # delegate_factory: callable that returns a stt.STT instance when needed
        super().__init__(capabilities=None)
        self._delegate_factory = delegate_factory
        self._delegate = None
        self._enabled = False

    def enable(self):
        self._enabled = True
        if self._delegate is None:
            self._delegate = self._delegate_factory()

    def disable(self):
        self._enabled = False
        # optional: keep delegate for reuse, or close if desired
        # if self._delegate:
        #     await self._delegate.close()

    async def transcribe(self, audio_stream) -> AsyncIterator[stt.TranscriptionChunk]:
        if not self._enabled or self._delegate is None:
            if False:
                yield
            return
        async for chunk in self._delegate.transcribe(audio_stream):
            yield chunk

    @asynccontextmanager
    async def stream(self, **kwargs) -> AsyncIterator[stt.TranscriptionStream]:
        if not self._enabled:
            class _NoopStream:
                def push_frame(self, frame: rtc.AudioFrame): pass
                async def close(self): pass
            yield _NoopStream()
            return
        # ensure delegate exists
        if self._delegate is None:
            self._delegate = self._delegate_factory()
        async with self._delegate.stream(**kwargs) as s:
            yield s
Usage: • Create session with this wrapper: session = AgentSession(stt=ConditionalSTT(lambda: deepgram.STT(model="nova-2-general")), ...) • Toggle in your RPC handler: ◦ To enable mic: call
session.input.set_audio_enabled(True)
then
session.stt.enable()
◦ To disable mic: call
session.input.set_audio_enabled(False)
then
session.stt.disable()
Option B — recreate session (less convenient) • Stop/close the AgentSession and create a new one with Deepgram when you want STT, or create session without STT and recreate with STT. This avoids read-only assignment but is heavier. Notes and tips • Deepgram 401 in your logs means your token was invalid when the plugin attempted to connect; using the ConditionalSTT prevents connection attempts until
.enable()
is called. • After disabling input, call
session.interrupt()
if you need to cancel in-flight processing. • Use your logging wrapper to confirm no Deepgram connections/logs appear while disabled. If you want, I’ll return a copy-paste-ready ConditionalSTT tailored to your imports and show the exact RPC handler edits. Which would you prefer?
s
so if we are not doing this at the starting the agent will connect to deepgram right automatically? even if we disable input audio?
t
Short answer: Yes — if you pass
deepgram.STT(...)
into
AgentSession
at construction, the Deepgram plugin will start its connection task immediately even if you call
session.input.set_audio_enabled(False)
afterward. Disabling input audio does not prevent the plugin from initializing or attempting a network connection. What to do • Use a lazy/no-op STT so Deepgram never connects until you want it to. Recommended option (safe, minimal changes) — ConditionalSTT • Create a wrapper STT that only instantiates/connects the real Deepgram client when you explicitly enable the mic. Copy-paste-ready pattern (adapt imports to your file):
Copy code
from contextlib import asynccontextmanager
from typing import AsyncIterator
from livekit.agents import stt, rtc
import logging

class ConditionalSTT(stt.STT):
    def __init__(self, factory):
        super().__init__(capabilities=None)
        self._factory = factory
        self._delegate = None
        self._enabled = False

    def enable(self):
        self._enabled = True
        if self._delegate is None:
            self._delegate = self._factory()

    def disable(self):
        self._enabled = False
        # optional: close delegate if it exposes a close method

    async def transcribe(self, audio_stream) -> AsyncIterator[stt.TranscriptionChunk]:
        if not self._enabled or self._delegate is None:
            if False:
                yield
            return
        async for chunk in self._delegate.transcribe(audio_stream):
            yield chunk

    @asynccontextmanager
    async def stream(self, **kwargs) -> AsyncIterator[stt.TranscriptionStream]:
        if not self._enabled:
            class _NoopStream:
                def push_frame(self, frame: rtc.AudioFrame): pass
                async def close(self): pass
            yield _NoopStream()
            return
        if self._delegate is None:
            self._delegate = self._factory()
        async with self._delegate.stream(**kwargs) as s:
            yield s
Usage • Construct session with the wrapper: session = AgentSession(stt=ConditionalSTT(lambda: deepgram.STT(model="...")), ...) • When user turns mic ON: session.stt.enable() session.input.set_audio_enabled(True) • When user turns mic OFF: session.input.set_audio_enabled(False) session.stt.disable() # stops new connections; existing connection may need cleanup depending on plugin Why this works • The wrapper prevents the Deepgram client from being instantiated/connected until you call
.enable()
. This avoids the 401/connect attempts and STT costs while the mic is off. Verification • No Deepgram connection logs (or 401 errors) until you call
.enable()
. • Timestamps in your dashboard/metrics should show no STT usage while disabled. If you want, I can: • Provide a tiny helper that closes the delegate cleanly when disabled, or • Show the exact RPC handler edits to call `.enable()`/`.disable()` from your UI. Which would you like?
s
from future import annotations import json import asyncio from contextlib import asynccontextmanager from typing import AsyncIterator import uvicorn import logging import os import certifi import time import multiprocessing from livekit.plugins import deepgram, groq, elevenlabs from dotenv import load_dotenv from custom_deepgram_tts import CustomDeepgramTTS from livekit.agents.voice import UserStateChangedEvent, AgentStateChangedEvent from livekit.agents.voice.io import PlaybackFinishedEvent from livekit import api, rtc from livekit.agents import ( tts, Agent, AgentSession, JobContext, llm, voice, WorkerOptions, cli, stt, room_io, RunContext, RoomInputOptions, RoomOutputOptions, ) from livekit.plugins import silero, noise_cancellation from livekit.agents import metrics, MetricsCollectedEvent, ConversationItemAddedEvent from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware # Set SSL_CERT_FILE to use certifi's certificate bundle os.environ["SSL_CERT_FILE"] = certifi.where() load_dotenv(".env.local") # ============================================================================= # FASTAPI APP # ============================================================================= app = FastAPI(title="BMMI HR Voice AI", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/api/check-online") def check_online(): print("Success") return "Success-12" # ============================================================================= # CUSTOM AGENT # ============================================================================= class MyAgent(Agent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.conversation_history: list[tuple[float, str, str]] = [] self.sound_enabled = True # Track sound state def log_transcription(self, timestamp: float, role: str, text: str): """Keep local conversation record.""" self.conversation_history.append((timestamp, role, text)) self.conversation_history.sort(key=lambda x: x[0]) logging.info(self.conversation_history) class LoggingTTS(tts.TTS): """ A TTS wrapper that logs the text being synthesized. """ def __init__(self, tts_service: tts.TTS): super().__init__( capabilities=tts_service.capabilities, sample_rate=tts_service.sample_rate, num_channels=tts_service.num_channels, ) self._tts = tts_service async def synthesize(self, text: str) -> tts.SynthesizeStream: logging.info(f"🎤 TTS synthesizing text: '{text}'") return await self._tts.synthesize(text) @asynccontextmanager async def stream(self, **kwargs) -> AsyncIterator[tts.SynthesizeStream]: """ Log text chunks as they are pushed to the TTS stream. """ async with self._tts.stream(**kwargs) as stream: original_push_text = stream.push_text def log_and_push(text: str): logging.info(f"🎤 TTS streaming text chunk: '{text}'") original_push_text(text) stream.push_text = log_and_push yield stream # ============================================================================= # ENTRYPOINT # ============================================================================= async def entrypoint(ctx: JobContext): await ctx.connect() participant = await ctx.wait_for_participant() logging.info(f"👤 Participant connected: {participant.identity}") # -- Setup Agent + LLM + TTS + STT -- agent = MyAgent( instructions="You are a helpful assistant that answers company-related questions clearly." ) last_spoken_info = {"text": "", "timestamp": 0.0} async def on_full_sentence(text: str): """Called when a full sentence is ready for TTS.""" nonlocal last_spoken_info timestamp = time.time() last_spoken_info = {"text": text, "timestamp": timestamp} agent.log_transcription(timestamp, "assistant", text) session = AgentSession( stt=deepgram.STT( model="nova-2-general", ), llm=groq.LLM(model="llama-3.1-8b-instant"), tts=LoggingTTS( tts_service=elevenlabs.TTS( voice_id="pNInz6obpgDQGcFmaJgB", model="eleven_turbo_v2" ) ), vad=silero.VAD.load(), ) @session.on("agent_state_changed") def on_agent_state_changed(ev: AgentStateChangedEvent): nonlocal last_spoken_info if ev.new_state == "speaking": logging.info("🗣️ Agent started speaking") # 🧠 NEW: Send final sentence payload 1 second after the agent starts speaking. if last_spoken_info["text"]: async def send_with_delay(): await asyncio.sleep(1) # Wait for 1 second try: payload = { "type": "final_sentence", "role": "assistant", "text": last_spoken_info["text"], "timestamp": last_spoken_info["timestamp"], } await ctx.room.local_participant.publish_data( json.dumps(payload).encode("utf-8"), reliable=True ) logging.info("✅ Sent final_sentence payload after 1s delay.") except Exception as e: logging.error(f"❌ Failed to send final sentence from on_agent_state_changed: {e}") asyncio.create_task(send_with_delay()) # ------------------------------------------------------------------------- # 🗣️ Log conversation items # ------------------------------------------------------------------------- @session.on("conversation_item_added") def on_conversation_item(event: ConversationItemAddedEvent): if event.item.role != "user": return text = event.item.text_content.lower() if "goodbye" in text: async def end_session(): logging.info("User said goodbye — interrupting agent and closing session") # 1️⃣ Interrupt any ongoing or queued LLM/TTS await session.interrupt() # 2️⃣ Say your final message await session.say("Goodbye, have a wonderful day!") # 3️⃣ Close session cleanly await asyncio.sleep(0.3) # let audio flush await session.close() asyncio.create_task(end_session()) # ------------------------------------------------------------------------- # 📩 Handle text-based interruptions # ------------------------------------------------------------------------- @ctx.room.local_participant.register_rpc_method("sound_control") async def handle_sound_control(data: rtc.RpcInvocationData): try: payload = json.loads(data.payload) sound_enabled = payload.get("soundEnabled") if sound_enabled is True: logging.info("🔊 User turned SOUND ON via RPC") session.output.set_audio_enabled(True) elif sound_enabled is False: logging.info("🔇 User turned SOUND OFF via RPC") session.output.set_audio_enabled(False) except Exception as e: logging.error(f"Error handling sound_control RPC: {e}") @ctx.room.local_participant.register_rpc_method("mic_control") async def handle_mic_control(data: rtc.RpcInvocationData): try: payload = json.loads(data.payload) mic_enabled = payload.get("micEnabled") if mic_enabled is True: logging.info("🎤 User turned MIC ON via RPC") session.input.set_audio_enabled(True) elif mic_enabled is False: logging.info("🔇 User turned MIC OFF via RPC") session.input.set_audio_enabled(False) except Exception as e: logging.error(f"Error handling mic_control RPC: {e}") @ctx.room.on("data_received") def on_data_received(data: rtc.DataPacket): try: payload = getattr(data, "payload", None) or getattr(data, "data", None) if not payload: return raw_text = payload.decode("utf-8", errors="ignore") if isinstance(payload, bytes) else str(payload) logging.info(f"💬 User Message: {raw_text}") except Exception as e: logging.error(f"Error handling data_received: {e}") # ------------------------------------------------------------------------- # 🎬 Start the agent session # ------------------------------------------------------------------------- await session.start( agent=agent, room=ctx.room, room_options=room_io.RoomOptions( audio_input=room_io.AudioInputOptions(), ), ) session.say("hello how are you") @session.output.audio.on("playback_finished") def _on_playback_finished(ev: PlaybackFinishedEvent): if ev.interrupted: logging.info("Agent was interrupted during playback") interruption_payload = { "type": "interrupted", "message": "User interrupted the assistant.", } asyncio.create_task( ctx.room.local_participant.publish_data( json.dumps(interruption_payload).encode("utf-8"), reliable=True )) # ============================================================================= # SERVER STARTUP # ============================================================================= async def start_api_server(port: int): logging.info(f"🚀 Starting FastAPI server on port {port}") config = uvicorn.Config(app=app, host="0.0.0.0", port=port, log_level="info") server = uvicorn.Server(config) await server.serve() def run_api_server(base_port): asyncio.run(start_api_server(base_port)) def start_livekit_agent(port: int, agent_name: str): logging.info(f"🤖 Starting LiveKit agent on port {port}") options = WorkerOptions(entrypoint_fnc=entrypoint, port=port,agent_name="BMMI HR VoiceAI") cli.run_app(options) def main(): logging.info("Starting BMMI HR Voice AI system") base_port = 5029 api_process = multiprocessing.Process(target=run_api_server, args=(base_port,)) api_process.start() try: start_livekit_agent(base_port + 1, "agent_name") except KeyboardInterrupt: logging.info("🛑 Shutdown signal received") finally: api_process.terminate() api_process.join() logging.info("✅ System shutdown complete") if name == "__main__": load_dotenv() main().....is this code correct like when i am turning on the mic then livekit is saying input attaching and if turned off saying dethatched
t
You're close — your toggles are working as expected from the AgentSession side, but a couple small points and one recommended change will make behavior match your intention exactly. What your current code does • When you call
session.input.set_audio_enabled(True)
the agent logs "input attaching" and the STT pipeline starts consuming audio. • When you call
session.input.set_audio_enabled(False)
the agent logs "input detached" and stops consuming audio. • However, because you construct the session with
stt=deepgram.STT(...)
, the Deepgram plugin attempts to connect immediately at session creation (which is why you saw the 401). Disabling input does not prevent the Deepgram client from initializing or connecting. What to change so Deepgram only connects when mic is ON • Use a Conditional/Lazy STT wrapper (recommended). This lets you create the AgentSession once but only instantiate/connect Deepgram when you explicitly enable the mic. Minimal ConditionalSTT to drop into your file (fits your imports):
Copy code
from contextlib import asynccontextmanager
from typing import AsyncIterator
from livekit.agents import stt, rtc

class ConditionalSTT(stt.STT):
    def __init__(self, factory):
        super().__init__(capabilities=None)
        self._factory = factory
        self._delegate = None
        self._enabled = False

    def enable(self):
        self._enabled = True
        if self._delegate is None:
            self._delegate = self._factory()

    def disable(self):
        self._enabled = False
        # optional: close delegate if it exposes close()

    async def transcribe(self, audio_stream) -> AsyncIterator[stt.TranscriptionChunk]:
        if not self._enabled or self._delegate is None:
            if False:
                yield
            return
        async for chunk in self._delegate.transcribe(audio_stream):
            yield chunk

    @asynccontextmanager
    async def stream(self, **kwargs) -> AsyncIterator[stt.TranscriptionStream]:
        if not self._enabled:
            class _NoopStream:
                def push_frame(self, frame: rtc.AudioFrame): pass
                async def close(self): pass
            yield _NoopStream()
            return
        if self._delegate is None:
            self._delegate = self._factory()
        async with self._delegate.stream(**kwargs) as s:
            yield s
How to use it in your code • Construct session with the wrapper: session = AgentSession(stt=ConditionalSTT(lambda: deepgram.STT(model="nova-2-general")), ...) • In your RPC handler: ◦ Turn mic ON: session.stt.enable() session.input.set_audio_enabled(True) • Turn mic OFF: session.input.set_audio_enabled(False) session.stt.disable() Why this fixes your issues • Deepgram will not attempt to connect until
session.stt.enable()
is called. • Disabling input remains effective for stopping audio processing. • You avoid the 401/connect attempts and any STT costs until the user explicitly enables the mic. Other small notes from your code • You're calling
session.say("hello how are you")
without awaiting — prefer
await session.say(...)
inside async context to ensure ordering. • After disabling input or output, call
await session.interrupt()
if you need to cancel in-flight work. • Ensure your Deepgram API key is valid before enabling; the 401 you saw is from invalid credentials. If you want, I can: • Provide a version of ConditionalSTT that cleanly closes the Deepgram delegate when disabled, or • Give the exact RPC-handler edits to wire enable/disable in your existing handlers. Which would you like next?
s
so in my code even when the mic is off is deepgram STT working?
t
Short answer: Yes — in your current code Deepgram is instantiated at session creation, so it will try to connect and run even when you immediately call
session.input.set_audio_enabled(False)
. Disabling input audio does not prevent the Deepgram plugin from initializing or making a websocket connection. What to do • Use a lazy/conditional STT so Deepgram only connects when you want it to. Recommended: the ConditionalSTT wrapper (creates Deepgram only on enable) and call: ◦ enable:
session.stt.enable(); session.input.set_audio_enabled(True)
◦ disable:
session.input.set_audio_enabled(False); session.stt.disable()
Verification • No Deepgram logs / no 401 errors until you call
.enable()
. • No STTMetrics or websocket connections in network logs while disabled. If you want, I’ll provide a copy-paste ConditionalSTT tailored to your imports and the exact RPC handler edits to wire enable/disable. Which would you prefer?
s
is there a way to know if deepgram is being used
t
Yes — here are practical ways to know whether Deepgram is being used in your agent. 1) Watch logs (fastest) • Look for Deepgram connection logs or the 401 you saw. When Deepgram is instantiated it will log attempts to open the websocket; absence of those logs means it hasn’t connected. • Add an explicit log when you create/enable Deepgram: ◦ e.g. logging.info("Attaching Deepgram STT") right before instantiating. 2) Instrument the STT wrapper • Use the ConditionalSTT/NoopSTT patterns we discussed and add logging inside: ◦ Log when
.enable()
instantiates Deepgram. ◦ Log inside
stream()
or
transcribe()
when frames are forwarded to the delegate. 3) Check runtime object type • At runtime inspect the session STT wrapper to see whether the delegate exists or is a Deepgram instance: ◦ logging.info(type(session.stt)) or, if using ConditionalSTT, logging.info(type(session.stt._delegate)). 4) LiveKit metrics/events & plugin metrics • Watch STT-related metrics/events (TTSMetrics/STTMetrics) in your application logs or LiveKit dashboard. If STTMetrics appear while mic is off, STT is running. • Correlate timestamps in the Usage/Billing view with suspected periods of STT activity. 5) Network / websocket inspection • Monitor outbound websocket connections from the process; Deepgram opens a ws to api.deepgram.com. No ws = not connected. 6) Definitive check (zero ambiguity) • Replace Deepgram with NoopSTT at startup. If your previous 401/Deepgram logs disappear, Deepgram was being used before. Re-enable Deepgram and confirm logs reappear. If you want, I can give a 2–3 line copy-paste log insertion for your ConditionalSTT/NoopSTT or show the exact command to inspect open websockets on your environment. Which would you prefer?
s
wait so even if we do disable the input audio deepgram is still streaming?
t
Short answer: Not necessarily — disabling input audio stops the agent from sending audio frames to the STT pipeline, but it does NOT prevent the Deepgram plugin from having already opened a websocket connection if you instantiated
deepgram.STT(...)
at session creation. That connection can still be established (and may incur cost or show connection activity) even with input disabled. What to check now • If you created the session with
stt=deepgram.STT(...)
, Deepgram will try to connect immediately (your 401 shows that). • If you used a Noop/Conditional wrapper and never enabled the delegate, Deepgram will not connect. • Verify by watching logs for Deepgram connect attempts (or 401s), monitoring outbound websockets, or checking STTMetrics. What to do to stop Deepgram completely until user enables mic • Use the ConditionalSTT or NoopSTT pattern so Deepgram is only instantiated/connected when you explicitly enable the mic (examples we discussed). • Or, instantiate Deepgram only inside your mic-ON handler (but note
AgentSession.stt
is read-only — use a wrapper or recreate the session). How to verify it's stopped • No Deepgram connection logs or 401s after disabling. • No STTMetrics events. • Inspect runtime type: log
type(session.stt)
and
getattr(session.stt, "_delegate", None)
for ConditionalSTT. If you want, I can give a tiny ConditionalSTT snippet tailored to your current imports and show the exact RPC edits to ensure Deepgram never connects until mic-ON. Would you like that?