Skip to content
Draft
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
24 changes: 24 additions & 0 deletions crewvoice/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# CrewVoice configuration — copy to .env and fill in.

# Anthropic API key — powers construction-aware translation (Claude Opus 4.8).
# Get one at https://console.anthropic.com/
ANTHROPIC_API_KEY=sk-ant-...

# ElevenLabs API key — powers voice cloning, text-to-speech, and speech-to-text.
# Get one at https://elevenlabs.io/
ELEVENLABS_API_KEY=...

# Port the local server listens on.
PORT=3000

# --- Delivery (Twilio) — optional; needed to send texts/voicemails to phones ---
# Get these from https://console.twilio.com/
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
# The Twilio phone number messages are sent from, in E.164 format.
TWILIO_FROM_NUMBER=+1...

# Public base URL of THIS server, reachable by Twilio, so it can fetch generated
# audio for voicemail playback (e.g. an ngrok URL in dev, or your deployed domain).
# Only needed for the "voicemail" channel.
PUBLIC_BASE_URL=https://your-domain.example.com
4 changes: 4 additions & 0 deletions crewvoice/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
.env
*.log
.DS_Store
97 changes: 97 additions & 0 deletions crewvoice/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# CrewVoice

**Your voice. Every worker's language.**

A contractor records their voice once. Then they type a message in English, pick a
crew member or a customer, and CrewVoice delivers it as a **voicemail or text in the
recipient's language — in the contractor's own cloned voice.**

Built for construction: dispatch a job to a Spanish-speaking crew, or follow up on a
bid with a homeowner who doesn't speak English — all in the voice they already know.
It also ships as an **MCP server**, so it plugs straight into the ChatGPT or Claude
you already use.

> CrewVoice by Allerion · $149/mo · early access.

---

## How it works

| Agent | What it does | Tech |
|-------|-------------|------|
| **Voice Cloner** | 30-second sample → a voice model that speaks any language as that person | ElevenLabs |
| **Translator** | English → the recipient's language, preserving trade terms (TPO, flashing, drip edge) | Claude Opus 4.8 |
| **Dispatcher** | Speaks the translation in the cloned voice; delivers as voicemail or text | ElevenLabs TTS + Twilio |
| **Two-way** | Crew/customer replies in their language → contractor gets English | ElevenLabs STT + Claude |

## Setup

```bash
cd crewvoice
npm install
cp .env.example .env # fill in ANTHROPIC_API_KEY and ELEVENLABS_API_KEY (Twilio optional)
npm start # http://localhost:3000
```

- **`ANTHROPIC_API_KEY`** and **`ELEVENLABS_API_KEY`** are required for the core demo
(clone → translate → speak).
- **Twilio** vars are optional — needed only to deliver texts/voicemails to real phones.
- For the **voicemail** channel, Twilio must be able to reach this server, so set
`PUBLIC_BASE_URL` (an ngrok URL in dev, or your deployed domain).

## Web demo

Open `http://localhost:3000`:

1. **Clone your voice** — record ~30s.
2. **Send a dispatch** — type in English, pick a language, hear it back in *your* voice.
3. **Two-way** — record a reply in any language, get it back in English.

## HTTP API

| Endpoint | Body | Returns |
|----------|------|---------|
| `POST /api/clone` | multipart `sample` (audio) | `{ voiceId }` |
| `POST /api/dispatch` | `{ text, targetLanguage, voiceId }` | `{ translation, audio (base64 mp3) }` |
| `POST /api/send` | `{ text, targetLanguage, to, channel: "text"\|"voicemail", voiceId? }` | `{ translation, sid }` |
| `POST /api/transcribe` | multipart `reply` (audio) | `{ original, languageCode, english }` |

## Use it inside Claude / ChatGPT (MCP)

CrewVoice ships as an MCP server (`mcp-server.js`) exposing three tools:
`clone_voice`, `translate_dispatch`, and `send_text_dispatch`.

Register it with any MCP client. For **Claude Code**:

```bash
claude mcp add crewvoice -- node /absolute/path/to/crewvoice/mcp-server.js
```

Or add it to a client's MCP config manually:

```json
{
"mcpServers": {
"crewvoice": {
"command": "node",
"args": ["/absolute/path/to/crewvoice/mcp-server.js"],
"env": {
"ANTHROPIC_API_KEY": "sk-ant-...",
"ELEVENLABS_API_KEY": "...",
"TWILIO_ACCOUNT_SID": "...",
"TWILIO_AUTH_TOKEN": "...",
"TWILIO_FROM_NUMBER": "+1..."
}
}
}
}
```

Then, from chat: *"Send the crew this in Spanish: start the south elevation, underlayment
down before noon"* — and CrewVoice translates and texts it.

## Notes

- This is an early-access MVP. Voice cloning uses ElevenLabs instant cloning; for
production, get consent from anyone whose voice you clone.
- Numbers and vendor choices are illustrative — validate before committing spend.
139 changes: 139 additions & 0 deletions crewvoice/lib.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// CrewVoice core — shared by the web server (server.js) and the MCP server (mcp-server.js).
//
// Capabilities:
// translate(text, lang) — construction-aware translation (Claude Opus 4.8)
// cloneVoice(name, buffer, mime) — ElevenLabs instant voice clone -> voice_id
// speak(voiceId, text) — ElevenLabs TTS in the cloned voice -> mp3 Buffer
// transcribe(buffer, mime) — ElevenLabs speech-to-text -> { text, language_code }
// sendText(to, body) — Twilio SMS delivery
// placeVoicemailCall(to, audioUrl)— Twilio call that plays a hosted mp3 (voicemail in your voice)

import Anthropic from "@anthropic-ai/sdk";

const ELEVENLABS = "https://api.elevenlabs.io/v1";
const TTS_MODEL = "eleven_multilingual_v2"; // multilingual so the cloned voice speaks the target language

let _anthropic;
function anthropic() {
if (!_anthropic) _anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
return _anthropic;
}

function elevenKey() {
const key = process.env.ELEVENLABS_API_KEY;
if (!key) throw new Error("ELEVENLABS_API_KEY is not set.");
return key;
}

// --- Construction-aware translation -----------------------------------------

const TRANSLATE_SYSTEM = `You are a translator for construction and roofing crews. You translate a contractor's spoken dispatch from English into the target language so a field crew or a customer can act on it immediately.

Rules:
- Output ONLY the translation. No preamble, no quotes, no notes, no alternatives.
- Preserve construction and roofing terminology precisely: TPO, EPDM, flashing, drip edge, underlayment, fascia, soffit, valley, ridge, penetration, elevation, square, course, etc. Use the term a working crew in the target language actually uses on site, not a literal dictionary gloss.
- Keep the tone direct and clear, like a foreman giving instructions or a contractor following up with a homeowner. Keep measurements and numbers exactly as given.
- If the input is already in the target language, return it unchanged.`;

export async function translate(text, targetLanguage) {
const response = await anthropic().messages.create({
model: "claude-opus-4-8",
max_tokens: 1024,
system: TRANSLATE_SYSTEM,
messages: [
{
role: "user",
content: `Target language: ${targetLanguage}\n\nText to translate:\n${text}`,
},
],
});
const block = response.content.find((b) => b.type === "text");
return block ? block.text.trim() : "";
}

// --- ElevenLabs voice ------------------------------------------------------

export async function cloneVoice(name, audioBuffer, mimeType) {
const form = new FormData();
form.append("name", name);
form.append(
"files",
new Blob([audioBuffer], { type: mimeType || "audio/webm" }),
"sample.webm"
);
const res = await fetch(`${ELEVENLABS}/voices/add`, {
method: "POST",
headers: { "xi-api-key": elevenKey() },
body: form,
});
if (!res.ok) throw new Error(`ElevenLabs clone failed (${res.status}): ${await res.text()}`);
return (await res.json()).voice_id;
}

export async function speak(voiceId, text) {
const res = await fetch(`${ELEVENLABS}/text-to-speech/${voiceId}`, {
method: "POST",
headers: {
"xi-api-key": elevenKey(),
"Content-Type": "application/json",
Accept: "audio/mpeg",
},
body: JSON.stringify({
text,
model_id: TTS_MODEL,
voice_settings: { stability: 0.5, similarity_boost: 0.8 },
}),
});
if (!res.ok) throw new Error(`ElevenLabs TTS failed (${res.status}): ${await res.text()}`);
return Buffer.from(await res.arrayBuffer());
}

export async function transcribe(audioBuffer, mimeType) {
const form = new FormData();
form.append("model_id", "scribe_v1");
form.append(
"file",
new Blob([audioBuffer], { type: mimeType || "audio/webm" }),
"reply.webm"
);
const res = await fetch(`${ELEVENLABS}/speech-to-text`, {
method: "POST",
headers: { "xi-api-key": elevenKey() },
body: form,
});
if (!res.ok) throw new Error(`ElevenLabs STT failed (${res.status}): ${await res.text()}`);
return res.json();
}

// --- Twilio delivery (text + voicemail) -------------------------------------

let _twilio;
async function twilioClient() {
if (_twilio) return _twilio;
const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN } = process.env;
if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN) {
throw new Error("TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN must be set to send messages.");
}
const { default: twilio } = await import("twilio");
_twilio = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN);
return _twilio;
}

export async function sendText(to, body) {
const from = process.env.TWILIO_FROM_NUMBER;
if (!from) throw new Error("TWILIO_FROM_NUMBER is not set.");
const client = await twilioClient();
const msg = await client.messages.create({ to, from, body });
return msg.sid;
}

// Places a phone call that plays a hosted mp3 — a voicemail in the contractor's voice.
// audioUrl must be publicly reachable by Twilio (see PUBLIC_BASE_URL in server.js).
export async function placeVoicemailCall(to, audioUrl) {
const from = process.env.TWILIO_FROM_NUMBER;
if (!from) throw new Error("TWILIO_FROM_NUMBER is not set.");
const client = await twilioClient();
const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response><Play>${audioUrl}</Play></Response>`;
const call = await client.calls.create({ to, from, twiml });
return call.sid;
}
83 changes: 83 additions & 0 deletions crewvoice/mcp-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// CrewVoice MCP server — exposes CrewVoice as tools inside ChatGPT, Claude, or any MCP client.
//
// Run over stdio: node mcp-server.js
// Then register it with your MCP client (see README → "Use it inside Claude/ChatGPT").
//
// Tools:
// clone_voice — clone a contractor's voice from a local audio file
// translate_dispatch — construction-aware English -> target-language translation (text only)
// send_text_dispatch — translate, then deliver as an SMS in the recipient's language

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFile } from "node:fs/promises";
import path from "node:path";
import dotenv from "dotenv";
import { translate, cloneVoice, sendText } from "./lib.js";

dotenv.config();

const server = new McpServer({ name: "crewvoice", version: "0.1.0" });

server.registerTool(
"clone_voice",
{
description:
"Clone a contractor's voice from a local audio sample (~30s). Returns a voiceId used for later dispatches.",
inputSchema: {
audioPath: z.string().describe("Absolute or relative path to an audio file (mp3/wav/webm/m4a)."),
name: z.string().optional().describe("A label for this voice, e.g. the contractor's name."),
},
},
async ({ audioPath, name }) => {
const buf = await readFile(audioPath);
const ext = path.extname(audioPath).slice(1).toLowerCase();
const mime = { mp3: "audio/mpeg", wav: "audio/wav", webm: "audio/webm", m4a: "audio/mp4" }[ext]
|| "audio/mpeg";
const voiceId = await cloneVoice(name || "CrewVoice contractor", buf, mime);
return { content: [{ type: "text", text: `Voice cloned. voiceId: ${voiceId}` }] };
}
);

server.registerTool(
"translate_dispatch",
{
description:
"Translate a construction dispatch from English into the crew's or customer's language, preserving trade terminology (TPO, flashing, drip edge, etc.). Returns the translated text only.",
inputSchema: {
text: z.string().describe("The message in English."),
targetLanguage: z.string().describe("Target language, e.g. 'Spanish'."),
},
},
async ({ text, targetLanguage }) => {
const translation = await translate(text, targetLanguage);
return { content: [{ type: "text", text: translation }] };
}
);

server.registerTool(
"send_text_dispatch",
{
description:
"Translate an English dispatch into the recipient's language and deliver it as an SMS text. Use for crew dispatches or customer/bid follow-ups. Requires Twilio configuration.",
inputSchema: {
text: z.string().describe("The message in English."),
targetLanguage: z.string().describe("Recipient's language, e.g. 'Spanish'."),
to: z.string().describe("Recipient phone number in E.164 format, e.g. +14155551234."),
},
},
async ({ text, targetLanguage, to }) => {
const translation = await translate(text, targetLanguage);
const sid = await sendText(to, translation);
return {
content: [
{ type: "text", text: `Sent to ${to} (sid ${sid}).\n\nDelivered text:\n${translation}` },
],
};
}
);

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("CrewVoice MCP server running on stdio.");
25 changes: 25 additions & 0 deletions crewvoice/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "crewvoice",
"version": "0.1.0",
"private": true,
"description": "CrewVoice — the vertical AI agent for construction crews. Speak a dispatch in English; your crew hears it in their language, in your cloned voice.",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"mcp": "node mcp-server.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.69.0",
"@modelcontextprotocol/sdk": "^1.13.0",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"multer": "^1.4.5-lts.1",
"twilio": "^5.3.0",
"zod": "^3.23.8"
}
}
Loading