Is it possible to have per-agent-tool thinking aud...
# ask-ai
f
Is it possible to have per-agent-tool thinking audio - i.e. different audio for different tools ?
t
Thanks for reaching out, Steve. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — good question. You're asking whether a single agent can use different "thinking" audio clips depending on which tool it's running. The short answer: yes. You can implement per-tool thinking audio by controlling playback programmatically with the BackgroundAudioPlayer. Instead of relying only on a global
thinking_sound
config, call
background_audio.play(...)
from inside each tool (or from the wrapper that invokes the tool) and pass the specific audio clip or source you want for that tool. The
play
call returns a
PlayHandle
so you can stop or await playback when the tool finishes, giving you precise start/stop control for each tool’s audio. If helpful, I can: • Show a short code snippet demonstrating how to call
background_audio.play()
at tool start and stop it at tool end. • Explain options for audio sources (built-in clips vs custom files) and how to manage probabilities or fallbacks. Which would you like next, and do you have a preferred runtime/language? Sources: External data and RAG | LiveKit Documentation | Agent speech and audio | LiveKit Documentation
f
Oh, I'm using TypeScript - which may make a difference!
t
Great — TypeScript is perfect for this. Below is a concise pattern you can use to implement per-tool thinking audio by calling the BackgroundAudioPlayer from inside each tool (or in the tool wrapper) so each tool can play its own clip and stop it when done. 1) Basic approach • On tool start, call
backgroundAudio.play(...)
with the audio clip for that tool. • Keep the returned
PlayHandle
so you can stop or await the playback when the tool finishes or when you want the thinking audio to end. • Use built-in clips or custom URLs/local files as the source depending on your environment. 2) Minimal TypeScript example
Copy code
// Assume these come from your agent runtime
import { BackgroundAudioPlayer, BuiltinAudioClip } from 'livekit-audio-sdk'; // adjust import to your SDK

// A simple mapping from tool name to audio clip
const toolThinkingAudio = {
  transcription: BuiltinAudioClip.SoftBeep,
  search: BuiltinAudioClip.WarmAmbience,
  summarize: '<https://example.com/audio/summarize_loop.mp3>', // custom URL
};

async function runTool(toolName: string, toolFn: () => Promise<any>, backgroundAudio: BackgroundAudioPlayer) {
  // Choose audio for this tool (fallback to a default)
  const audioSource = toolThinkingAudio[toolName] ?? BuiltinAudioClip.DefaultThinking;

  // Start thinking audio and keep the handle
  const handle = await backgroundAudio.play({ source: audioSource, loop: true });

  try {
    // Run the tool's logic
    const result = await toolFn();
    return result;
  } finally {
    // Stop the thinking audio reliably when tool completes or errors
    await handle.stop();
  }
}
3) Notes and customization ideas • Looping: For ongoing tool work, start with
loop: true
. Stop the handle when the tool ends. • Timing: You can await
handle.finished()
if you want to let the clip run to completion instead of stopping it early. • Probabilities/fallbacks: If you want randomized selection, store an array of audio configs per tool and pick one when starting playback. • Concurrency: If multiple tools may run concurrently and you want separate audio per tool, keep separate handles and ensure your audio system supports multiple simultaneous players or mix channels. • Source types: Use built-in clips for consistent UX or host short loopable files for custom sounds. URLs or local assets both work if the player supports them in your runtime. Would you like a more complete sample showing: 1) randomized selection from multiple clips per tool, or 2) handling concurrent tool runs with separate mixing channels? Tell me which and I’ll provide the TypeScript snippet.
f
( messy! shame there's no
audio
parameter to llm.tool() ! )
t
Totally hear you — it would be cleaner if
llm.tool()
accepted an
audio
param. Even without that, you can get the same outcome with a small wrapper pattern in TypeScript that starts and stops thinking audio around each tool call. Below I’ll: • Acknowledge the limitation • Show a robust TypeScript pattern you can drop into your agent runtime • Point out options for concurrency and randomization Acknowledgement It’s understandable to find it messy that tools don’t accept an
audio
argument directly — wrapping tool execution to control
BackgroundAudioPlayer
gives you explicit, predictable control and lets you implement per-tool audio reliably. TypeScript wrapper pattern (robust) 1) Behavior: • Start a chosen audio clip when the tool begins. • Keep the returned PlayHandle so you can stop or await it when the tool finishes (success or error). • Support looping, randomized clip selection, custom URLs, and concurrent tool runs. 2) Example
Copy code
// Adjust imports to your actual SDK
import { BackgroundAudioPlayer, PlayHandle, BuiltinAudioClip } from 'livekit-audio-sdk';

// Map each tool to either a single source or an array of sources (for random selection)
type AudioSource = string | BuiltinAudioClip;
const toolAudioMap: Record<string, AudioSource | AudioSource[]> = {
  transcription: BuiltinAudioClip.SoftBeep,
  search: [BuiltinAudioClip.WarmAmbience, BuiltinAudioClip.ToneLoop],
  summarize: '<https://example.com/audio/summarize_loop.mp3>',
  default: BuiltinAudioClip.DefaultThinking,
};

function pickAudioForTool(toolName: string): AudioSource {
  const entry = toolAudioMap[toolName] ?? toolAudioMap.default;
  if (Array.isArray(entry)) {
    return entry[Math.floor(Math.random() * entry.length)];
  }
  return entry;
}

// Wrapper that runs a tool with thinking audio
export async function runToolWithAudio<T>(
  toolName: string,
  toolFn: () => Promise<T>,
  backgroundAudio: BackgroundAudioPlayer,
  opts?: { loop?: boolean; allowOverlap?: boolean } // allowOverlap lets multiple tools play concurrently
): Promise<T> {
  const audioSource = pickAudioForTool(toolName);
  // Start playback. Provide options like loop. SDK may accept different shapes; adapt as needed.
  const handle: PlayHandle = await backgroundAudio.play({ source: audioSource, loop: opts?.loop ?? true });

  try {
    const result = await toolFn();
    return result;
  } finally {
    // Ensure we stop audio even on errors
    try {
      // If you want to let clip finish, use handle.finished() instead of stop()
      await handle.stop();
    } catch (e) {
      // swallow stop errors but optionally log them
      console.warn(`Failed to stop audio for ${toolName}`, e);
    }
  }
}
Concurrency notes • If multiple tools may run simultaneously and you want separate audio per tool, the player and your environment must support concurrent playback (multiple handles). Keep a handle per tool and stop each independently. • If you prefer a single global thinking track that changes per tool, you can stop the current handle before starting a new one. Advanced options (pick any you'd like) • Fade-in/out: If your audio player supports volume control, fade between clips to avoid abrupt cuts. • Priority: Add a priority field so high-priority tools interrupt lower-priority audio. • Channel mixing: If your runtime supports channels, play each tool on separate channels to mix or duck audio automatically. • Probabilities: Store weighted arrays of clips and sample by weight for varied UX. Would you like: 1) A version that supports weighted-random selection, 2) An example demonstrating concurrent tool runs and mix/duck behavior, or 3) A snippet integrating this wrapper with your existing agent/tool registration code? Tell me which and I’ll provide the tailored TypeScript snippet.
f
are you sure about this - does 'livekit-audio-sdk' actually exist?!
t
Great catch — thanks for asking. You’re right to question that:
livekit-audio-sdk
does not exist. That was an error earlier. What you should use • Use the
BackgroundAudioPlayer
from the official
@livekit/agents
package. That is the correct place for agent audio features in TypeScript/Node.js. • There is no
audio
parameter on
llm.tool()
(or
tool()
); you trigger thinking/ambient audio manually by calling
backgroundAudio.play()
inside your tool code or wrapper. Quick TypeScript example (minimal, correct imports)
Copy code
import { BackgroundAudioPlayer, voice } from '@livekit/agents';
const { BuiltinAudioClip } = voice;

// Create the player (connect it after agent/room setup)
const backgroundAudio = new BackgroundAudioPlayer();

// Example wrapper that plays per-tool audio
async function runToolWithAudio<T>(
  toolName: string,
  toolFn: () => Promise<T>,
  audioSource: string | typeof BuiltinAudioClip[keyof typeof BuiltinAudioClip]
) {
  // Start thinking audio (loop while tool runs)
  const handle = await backgroundAudio.play({ source: audioSource, loop: true });

  try {
    return await toolFn();
  } finally {
    // stop when the tool finishes or errors
    await handle.stop();
  }
}
Implementation notes and tips • Install:
npm install @livekit/agents
. • Use built-in clips (e.g.,
BuiltinAudioClip.KEYBOARD_TYPING
) or custom files/URLs. • For randomized or weighted clips per tool, pick the clip before calling
play()
. • For concurrent tool runs: ◦ If you want separate audio per tool, keep one handle per tool and ensure your environment/player supports multiple simultaneous handles. ◦ If you want a single global track that changes per tool, stop the current handle before starting the new one. • In TypeScript the
thinking_sound
constructor option (available in Python) is not available — manual
play()
calls are the way to go. Would you like: 1) A fuller example that wires this into an agent lifecycle (connect/start/stop background audio), or 2) A snippet showing weighted-random selection of clips per tool, or 3) Concurrency examples (separate handles vs global switching)? Tell me which and I’ll provide the tailored TypeScript snippet.
f
simple wrapper for llm.tool() that extends it's parameters argument with an
audio
parameter that defines the audio for that tool.
t
Thanks — I’ll give you a compact, practical TypeScript wrapper that extends
llm.tool()
-style registration so each tool can declare an
audio
parameter. The wrapper will: • Accept an
audio
option (builtin clip, URL, or undefined). • Start looping thinking audio when the tool runs. • Stop the thinking audio when the tool completes or errors. • Play safely when multiple tools run concurrently (keeps one handle per invocation). Assumptions: • You’re using the LiveKit Agents Node SDK:
@livekit/agents
. • You have a
BackgroundAudioPlayer
instance available and started for the agent/session. • Adjust imports and integration points to your agent runtime as needed. Wrapper snippet
Copy code
import { BackgroundAudioPlayer, voice } from '@livekit/agents';
const { BuiltinAudioClip } = voice;

type AudioSource = string | typeof BuiltinAudioClip[keyof typeof BuiltinAudioClip];

type ToolHandler<T = any> = (input: any) => Promise<T>;

type ToolOptions = {
  description?: string;
  audio?: AudioSource; // new audio param per tool
  loop?: boolean;      // whether to loop the thinking audio (default true)
};

class ToolRegistry {
  private backgroundAudio: BackgroundAudioPlayer;

  constructor(backgroundAudio: BackgroundAudioPlayer) {
    this.backgroundAudio = backgroundAudio;
  }

  // Wrapper for registering a tool (mimics llm.tool-style signature)
  registerTool<T = any>(name: string, opts: ToolOptions, handler: ToolHandler<T>) {
    // Replace this with your actual `llm.tool()` registration call.
    // The returned function below is what the runtime will call when invoking the tool.
    const wrapped = async (input: any) => {
      const audioSource = opts.audio;
      let handle: any | null = null;

      try {
        if (audioSource) {
          // Start thinking audio; loop by default unless explicitly disabled
          handle = await this.backgroundAudio.play({
            source: audioSource,
            loop: opts.loop ?? true,
          });
        }

        // Run actual tool logic
        return await handler(input);
      } finally {
        // Ensure we stop/cleanup audio even if handler throws
        if (handle) {
          try {
            await handle.stop();
          } catch (e) {
            // optional: log but don't throw
            console.warn(`Failed to stop thinking audio for tool ${name}`, e);
          }
        }
      }
    };

    // Example: wire into your agent/tool system here.
    // e.g., llm.tool(name, opts.description, wrapped);
    // For demonstration we return the wrapped function so caller can register it.
    return wrapped;
  }
}
Usage example
Copy code
// assume backgroundAudio is an instance you've created and started
const registry = new ToolRegistry(backgroundAudio);

// Register a tool with a builtin clip
const genJoke = registry.registerTool(
  'generate_joke',
  { description: 'Generate a short joke', audio: BuiltinAudioClip.KEYBOARD_TYPING },
  async (input) => {
    // simulate work
    await new Promise((r) => setTimeout(r, 1200));
    return { text: 'Why did the chicken cross the road? To get to the other side!' };
  }
);

// Register a tool with a custom URL
const summarize = registry.registerTool(
  'summarize',
  { description: 'Summarize text', audio: '<https://example.com/audio/summarize_loop.mp3>' },
  async (input) => {
    /* ... */
  }
);

// Now register these wrapped handlers with your agent/llm tooling system.
// e.g., llm.tool('generate_joke', 'Generate a short joke', genJoke);
Notes and tips • If you want a global default thinking audio, check
opts.audio
and fall back to a global clip if missing. • For weighted/random selection, set
audio
to a helper that picks one before calling
play()
. • If your runtime does not support multiple concurrent audio streams, consider stopping the global handle before starting a new one instead of allowing overlap. • If you prefer to let a clip finish rather than stopping it, use
handle.finished()
instead of
handle.stop()
. Want this adapted to: 1) automatically call
llm.tool(...)
for you inside the registry, or 2) include weighted-random selection, or 3) add fade in/out using volume controls if supported? Tell me which and I’ll extend the snippet.
f
it should take 1 argument, that matches the normal llm.tool() argument - but with an additional (optional)
audio
member.
t
Thanks — understood. Below is a concise, single-argument TypeScript wrapper that matches the normal
llm.tool()
signature but accepts an extra optional
audio
member. It: • Takes one options object (same shape as
llm.tool()
parameters + optional
audio
). • Starts looping thinking audio when the tool is invoked. • Stops that audio when the tool finishes or errors. • Returns the wrapped tool function so you can pass it directly to your
llm.tool(...)
registration. Assumptions: • You have
@livekit/agents
installed and a running
BackgroundAudioPlayer
instance (connected/started). •
llm.tool(name, description, handler)
is how you register tools in your runtime — replace that call with your actual registration if different. Wrapper implementation
Copy code
import { BackgroundAudioPlayer, voice } from '@livekit/agents';
const { BuiltinAudioClip } = voice;

type AudioSource = string | typeof BuiltinAudioClip[keyof typeof BuiltinAudioClip];

// Matches the normal single-argument style for llm.tool(), extended with `audio`
type LlmToolOptions = {
  name: string;
  description?: string;
  // handler is the function that runs when the tool is invoked
  handler: (input: any) => Promise<any>;
  // optional new audio field
  audio?: AudioSource | AudioSource[]; // allow array for random choice
  loop?: boolean; // optional per-tool loop override (defaults true)
};

export class LlmToolWrapper {
  private backgroundAudio: BackgroundAudioPlayer;
  private defaultAudio?: AudioSource;

  constructor(backgroundAudio: BackgroundAudioPlayer, opts?: { defaultAudio?: AudioSource }) {
    this.backgroundAudio = backgroundAudio;
    this.defaultAudio = opts?.defaultAudio;
  }

  // Single-argument wrapper that returns a handler ready for registration
  wrapTool(opts: LlmToolOptions) {
    const { name, handler, audio, loop } = opts;

    // Helper to pick audio (supports single or array)
    const pickAudio = (): AudioSource | undefined => {
      if (audio == null) return this.defaultAudio;
      if (Array.isArray(audio) && audio.length > 0) {
        return audio[Math.floor(Math.random() * audio.length)];
      }
      return audio as AudioSource;
    };

    // The function to register with llm.tool(...)
    const wrappedHandler = async (input: any) => {
      const audioSource = pickAudio();
      let playHandle: any | null = null;

      try {
        if (audioSource) {
          // start thinking audio; loop by default unless explicitly false
          playHandle = await this.backgroundAudio.play({
            source: audioSource,
            loop: loop ?? true,
          });
        }

        // run the real tool
        return await handler(input);
      } finally {
        if (playHandle) {
          try {
            await playHandle.stop();
          } catch (e) {
            // don't throw if stopping fails; optionally log
            console.warn(`Failed to stop thinking audio for tool ${name}`, e);
          }
        }
      }
    };

    // Return an object matching llm.tool's signature pieces so caller can register
    return {
      name: opts.name,
      description: opts.description,
      handler: wrappedHandler,
    };
  }
}
Example usage
Copy code
// assume `backgroundAudio` is created and started elsewhere
const wrapper = new LlmToolWrapper(backgroundAudio, { defaultAudio: BuiltinAudioClip.DEFAULT_THINKING });

// Define a tool with per-tool builtin clip
const jokeTool = wrapper.wrapTool({
  name: 'generate_joke',
  description: 'Return a short joke',
  audio: BuiltinAudioClip.KEYBOARD_TYPING,
  handler: async (input) => {
    // tool logic
    await new Promise((r) => setTimeout(r, 1200));
    return { text: 'A joke' };
  },
});

// Define a tool with randomized selection between clips
const summarizeTool = wrapper.wrapTool({
  name: 'summarize',
  description: 'Summarize text',
  audio: [BuiltinAudioClip.WARM_AMBIENCE, '<https://example.com/summarize_loop.mp3>'],
  handler: async (input) => { /* ... */ },
});

// Register with your LLM/tool system (example)
llm.tool(jokeTool.name, jokeTool.description, jokeTool.handler);
llm.tool(summarizeTool.name, summarizeTool.description, summarizeTool.handler);
Notes and tips • If you want the wrapper to call
llm.tool(...)
directly, I can change
wrapTool
to register immediately instead of returning the pieces. • If you prefer weighted random selection, I can show a small helper to sample by weight. • For concurrency: ◦ This pattern keeps one play handle per invocation, so concurrent tool runs will play concurrently if the environment supports multiple handles. ◦ If you prefer global switching (one track at a time), modify the wrapper to stop any existing global handle before starting a new one. Want me to adapt this so the wrapper directly calls
llm.tool(...)
for you, or add weighted-random selection?