Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bot_output_audio/.env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
RECALL_API_KEY=RECALL_API_KEY
RECALL_REGION=RECALL_REGION # e.g. us-west-2, us-east-1, eu-central-1, ap-northeast-1
MEETING_URL=MEETING_URL # e.g. any Zoom, Google Meet, Microsoft Teams, Webex, or GoToMeeting URL
75 changes: 75 additions & 0 deletions bot_output_audio/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Output audio from a bot

This example demonstrates how to create a bot that outputs audio when it starts recording. The bot uses the `automatic_audio_output` configuration to play a base64-encoded MP3 file when the bot begins recording in a meeting.

## Pre-requisites

- [Node.js](https://nodejs.org/en/download)
- [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)

## Quickstart

### 1. Set up environment variables

Copy the `.env.sample` file and rename it to `.env`:

```bash
cp .env.sample .env
```

Then fill out the variables in the `.env` file:

- `RECALL_API_KEY` - Your Recall.ai API key
- `RECALL_REGION` - Your Recall.ai region (e.g., `us-west-2`)
- `MEETING_URL` - The meeting URL for the bot to join

### 2. Add your audio

Replace the base64-encoded MP3 audio in the `src/base64/` folder:

- `in_call_recording.txt` - Base64-encoded MP3 played when the bot starts recording

To convert an audio file to base64:

```bash
base64 -i your_audio.mp3 > src/base64/in_call_recording.txt
```

**Audio requirements:**

- Must be MP3 format

### 3. Install dependencies

Open this directory in a terminal and run:

```bash
npm install
```

### 4. Start the server

```bash
npm run dev
```

This will start a server on port 4000.

### 5. Create a bot

In a new terminal, trigger bot creation:

```bash
curl http://localhost:4000
```

Or use the provided script:

```bash
chmod +x run.sh
./run.sh
```

### 6. View the output

The bot will join the meeting and play your audio clip when it starts recording.
24 changes: 24 additions & 0 deletions bot_output_audio/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "bot_output_audio",
"version": "1.0.0",
"description": "Create a bot that outputs audio using base64-encoded MP3 data",
"main": "index.ts",
"scripts": {
"dev": "ts-node src/index.ts"
},
"author": "Gerry Saporito",
"license": "MIT",
"devDependencies": {
"@types/is-base64": "^1.1.3",
"@types/node": "^24.10.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"dependencies": {
"dotenv": "^17.2.3",
"file-type": "^21.1.1",
"http": "^0.0.1-security",
"is-base64": "^1.1.0",
"zod": "^4.1.13"
}
}
4 changes: 4 additions & 0 deletions bot_output_audio/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail

curl http://localhost:4000
1 change: 1 addition & 0 deletions bot_output_audio/src/base64/in_call_recording.txt

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions bot_output_audio/src/bot_output_audio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileTypeFromBuffer } from "file-type";
import isBase64 from "is-base64";
import { z } from "zod";
import { env } from "./config/env";

/**
* Create a bot that outputs audio using base64-encoded MP3 data.
*/
export async function bot_output_audio() {
const base64_dir = resolve("src", "base64");
const BASE64_IN_CALL_RECORDING = await import_base64({ path_to_file: resolve(base64_dir, "in_call_recording.txt") });

if (!BASE64_IN_CALL_RECORDING) {
throw new Error("No base64 data found.");
}

const body: Record<string, any> = {
meeting_url: env.MEETING_URL,
recording_config: {
start_recording_on: "participant_join",
},
};
if (BASE64_IN_CALL_RECORDING) {
if (!body.automatic_audio_output) {
body.automatic_audio_output = {};
}
body.automatic_audio_output.in_call_recording = {
data: {
kind: "mp3",
b64_data: BASE64_IN_CALL_RECORDING,
},
};
}

const response = await fetch(`https://${env.RECALL_REGION}.recall.ai/api/v1/bot`, {
method: "POST",
headers: {
"Authorization": `${env.RECALL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});

if (!response.ok) {
throw new Error(`Failed to create bot output audio: ${await response.text()}`);
}

const bot = await response.json();
console.log(`Created bot: ${JSON.stringify(bot)}`);
return bot;
}

/**
* Validate base64 data is a valid MP3.
* Throws if base64 data isn't valid.
*/
async function validate_base64_mp3(args: { base64: string }) {
const { base64 } = z.object({ base64: z.string() }).parse(args);

const buffer = Buffer.from(base64, "base64");

if (!isBase64(base64, { allowMime: false })) {
throw new Error("Base64 data is not valid.");
}
if (buffer.length === 0) {
throw new Error("Base64 data is empty.");
}
const type = await fileTypeFromBuffer(buffer);
if (type?.mime !== "audio/mpeg") {
throw new Error(`Base64 data is not an MP3. Received '${type?.mime}'.`);
}
}

/**
* Import the base64 MP3 audio.
* Throws if base64 data isn't valid.
*/
async function import_base64(args: { path_to_file: string }) {
const { path_to_file } = z.object({ path_to_file: z.string() }).parse(args);
const base64 = readFileSync(path_to_file, "utf8").trim();
try {
await validate_base64_mp3({ base64 });
return base64;
} catch (error) {
throw new Error(`Failed to import base64: ${error instanceof Error ? error.message : String(error)} Path: ${args.path_to_file}`);
}
}
6 changes: 6 additions & 0 deletions bot_output_audio/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import dotenv from "dotenv";
import { EnvSchema } from "../schemas/EnvSchema";

dotenv.config();

export const env = EnvSchema.parse(process.env);
29 changes: 29 additions & 0 deletions bot_output_audio/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import http from "http";
import { bot_output_audio } from "./bot_output_audio";
import { env } from "./config/env";

const server = http.createServer();

/**
* HTTP server for handling HTTP requests.
*/
server.on("request", async (req, res) => {
try {
const bot = await bot_output_audio();

console.log(`Created bot: ${req.method} ${req.url}`);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(bot));
} catch (error) {
console.error(`Error creating bot: ${req.method} ${req.url}`, error);
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
}
});

/**
* Start the server.
*/
server.listen(env.PORT, "0.0.0.0", () => {
console.log(`Server is running on port ${env.PORT}`);
});
8 changes: 8 additions & 0 deletions bot_output_audio/src/schemas/EnvSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { z } from "zod";

export const EnvSchema = z.object({
PORT: z.string().transform((val) => parseInt(val)).default(4000),
RECALL_REGION: z.string(),
RECALL_API_KEY: z.string(),
MEETING_URL: z.string(),
});
14 changes: 14 additions & 0 deletions bot_output_audio/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true
},
"include": ["src"]
}
10 changes: 10 additions & 0 deletions bot_output_audio/types/is-base64.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
declare module "is-base64" {
interface IsBase64Options {
allowMime?: boolean;
allowEmpty?: boolean;
}

function isBase64(value: string, options?: IsBase64Options): boolean;

export default isBase64;
}
42 changes: 29 additions & 13 deletions bot_output_image/src/bot_output_image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,41 @@ export async function bot_output_image() {
const BASE64_IN_CALL_NOT_RECORDING = await import_base64({ path_to_file: resolve(base64_dir, "in_call_not_recording.txt") });
const BASE64_IN_CALL_RECORDING = await import_base64({ path_to_file: resolve(base64_dir, "in_call_recording.txt") });

if (!BASE64_IN_CALL_NOT_RECORDING && !BASE64_IN_CALL_RECORDING) {
throw new Error("No base64 data found.");
}

const body: Record<string, any> = {
"meeting_url": env.MEETING_URL,
};
if (BASE64_IN_CALL_NOT_RECORDING) {
if (!body.automatic_video_output) {
body.automatic_video_output = {};
}
body.automatic_video_output.in_call_not_recording = {
"kind": "jpeg",
"b64_data": BASE64_IN_CALL_NOT_RECORDING,
};
}
if (BASE64_IN_CALL_RECORDING) {
if (!body.automatic_video_output) {
body.automatic_video_output = {};
}
body.automatic_video_output.in_call_recording = {
data: {
"kind": "jpeg",
"b64_data": BASE64_IN_CALL_RECORDING,
},
};
}

const response = await fetch(`https://${env.RECALL_REGION}.recall.ai/api/v1/bot`, {
method: "POST",
headers: {
"Authorization": `${env.RECALL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"meeting_url": env.MEETING_URL,
"automatic_video_output": {
"in_call_not_recording": {
"kind": "jpeg",
"b64_data": BASE64_IN_CALL_NOT_RECORDING,
},
"in_call_recording": {
"kind": "jpeg",
"b64_data": BASE64_IN_CALL_RECORDING,
},
},
}),
body: JSON.stringify(body),
});

if (!response.ok) {
Expand Down
Loading
Loading