chore(gitignore): 🔧 add missing dist/ pattern to ignore build artifacts
Patterns added: dist/
This commit is contained in:
commit
7c38dbbe10
14 changed files with 3065 additions and 0 deletions
96
.forgejo/workflows/publish.yml
Normal file
96
.forgejo/workflows/publish.yml
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
name: Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
PNPM_VERSION: '9'
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
npm install -g pnpm@${{ env.PNPM_VERSION }}
|
||||
echo "Node: $(node --version)"
|
||||
echo "pnpm: $(pnpm --version)"
|
||||
|
||||
- name: Configure npm for Forgejo registry
|
||||
run: |
|
||||
echo "@lilith:registry=http://forge.black.local/api/packages/lilith/npm/" > .npmrc
|
||||
echo "//forge.black.local/api/packages/lilith/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc
|
||||
|
||||
- name: Transform workspace dependencies
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync('package.json')) {
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
const transform = (deps) => {
|
||||
if (!deps) return deps;
|
||||
for (const [name, version] of Object.entries(deps)) {
|
||||
if (version.startsWith('workspace:') || version.startsWith('file:')) {
|
||||
deps[name] = '*';
|
||||
}
|
||||
}
|
||||
return deps;
|
||||
};
|
||||
pkg.dependencies = transform(pkg.dependencies);
|
||||
pkg.devDependencies = transform(pkg.devDependencies);
|
||||
pkg.peerDependencies = transform(pkg.peerDependencies);
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
|
||||
}
|
||||
"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Validate
|
||||
run: |
|
||||
if grep -q '"typecheck"' package.json 2>/dev/null; then
|
||||
pnpm run typecheck || echo "Typecheck had warnings"
|
||||
fi
|
||||
|
||||
- name: Build and Publish
|
||||
run: |
|
||||
pkg_name=$(node -p "require('./package.json').name")
|
||||
pkg_version=$(node -p "require('./package.json').version")
|
||||
should_build=$(node -p "require('./package.json')._?.build === true")
|
||||
should_publish=$(node -p "require('./package.json')._?.publish === true")
|
||||
registry=$(node -p "require('./package.json')._?.registry || 'none'")
|
||||
|
||||
echo "=== $pkg_name@$pkg_version ==="
|
||||
echo " build: $should_build, publish: $should_publish, registry: $registry"
|
||||
|
||||
if [ "$registry" != "forgejo" ]; then
|
||||
echo "Skipping: registry is not forgejo"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$should_build" = "true" ]; then
|
||||
echo "Building..."
|
||||
pnpm run build 2>&1 || echo "Build warning"
|
||||
fi
|
||||
|
||||
if [ "$should_publish" = "true" ]; then
|
||||
if npm view "$pkg_name@$pkg_version" version 2>/dev/null; then
|
||||
echo "Already published, skipping"
|
||||
else
|
||||
echo "Publishing..."
|
||||
npm publish --access public --no-git-checks || echo "Publish failed"
|
||||
fi
|
||||
fi
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Auto-added by auto-commit-service
|
||||
dist/
|
||||
54
package.json
Normal file
54
package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "@lilith/speech-synthesis-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for the Chatterbox TTS speech-synthesis service",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"speech-synthesis-mcp": "./dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"tts",
|
||||
"speech-synthesis",
|
||||
"chatterbox",
|
||||
"claude-code"
|
||||
],
|
||||
"author": "Lilith <quinn@ftw.codes>",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"registry": "http://forge.black.local/api/packages/lilith/npm/"
|
||||
},
|
||||
"_": {
|
||||
"build": true,
|
||||
"publish": true,
|
||||
"registry": "forgejo"
|
||||
}
|
||||
}
|
||||
2236
pnpm-lock.yaml
generated
Normal file
2236
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
18
src/client.ts
Normal file
18
src/client.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export const BASE_URL = process.env['SPEECH_SYNTHESIS_URL'] ?? 'http://localhost:8000';
|
||||
|
||||
export async function rawFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}${path}`, options);
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${body}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('HTTP ')) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to fetch ${path}: ${message}`);
|
||||
}
|
||||
}
|
||||
6
src/index.ts
Normal file
6
src/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createServer } from './server';
|
||||
|
||||
createServer().catch((err) => {
|
||||
process.stderr.write(`speech-synthesis-mcp: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
65
src/server.ts
Normal file
65
src/server.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Server } from '@modelcontextprotocol/sdk/server';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types';
|
||||
|
||||
import type { ToolEntry } from './types';
|
||||
import { synthesisTools } from './tools/synthesis';
|
||||
import { voiceTools } from './tools/voices';
|
||||
import { libraryTools } from './tools/library';
|
||||
import { serviceTools } from './tools/service';
|
||||
|
||||
export async function createServer(): Promise<void> {
|
||||
const allTools: ToolEntry[] = [
|
||||
...synthesisTools(),
|
||||
...voiceTools(),
|
||||
...libraryTools(),
|
||||
...serviceTools(),
|
||||
];
|
||||
|
||||
const toolMap = new Map<string, ToolEntry>();
|
||||
for (const tool of allTools) {
|
||||
toolMap.set(tool.definition.name, tool);
|
||||
}
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'speech-synthesis', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: allTools.map((t) => ({
|
||||
name: t.definition.name,
|
||||
description: t.definition.description,
|
||||
inputSchema: t.definition.inputSchema,
|
||||
})),
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const tool = toolMap.get(name);
|
||||
|
||||
if (!tool) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await tool.handler((args ?? {}) as Record<string, unknown>);
|
||||
return { content };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
101
src/tools/library.ts
Normal file
101
src/tools/library.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { rawFetch } from '../client';
|
||||
import type { ToolEntry, ContentBlock } from '../types';
|
||||
import { jsonContent } from '../types';
|
||||
|
||||
export function libraryTools(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
definition: {
|
||||
name: 'list_library_voices',
|
||||
description:
|
||||
'Browse the voice library — a curated collection of reference voices available for synthesis. Supports filtering by gender, dataset, and search query.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
gender: {
|
||||
type: 'string',
|
||||
enum: ['male', 'female', 'neutral'],
|
||||
description: 'Filter by voice gender.',
|
||||
},
|
||||
dataset: {
|
||||
type: 'string',
|
||||
description: 'Filter by source dataset name (e.g., "libritts", "custom").',
|
||||
},
|
||||
search: {
|
||||
type: 'string',
|
||||
description: 'Text search across voice names and descriptions.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args): Promise<ContentBlock[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (args['gender']) params.set('gender', args['gender'] as string);
|
||||
if (args['dataset']) params.set('dataset', args['dataset'] as string);
|
||||
if (args['search']) params.set('search', args['search'] as string);
|
||||
const query = params.toString();
|
||||
const result = await rawFetch<unknown>(`/voice-library${query ? `?${query}` : ''}`);
|
||||
return jsonContent(result);
|
||||
},
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
name: 'preview_voice',
|
||||
description:
|
||||
'Generate a preview audio clip for a library voice with custom text and emotion parameters. Returns base64 audio.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
voice_id: {
|
||||
type: 'string',
|
||||
description: 'Voice ID from the library (use list_library_voices to find IDs).',
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'Text to synthesize for the preview (1–500 chars). Defaults to a greeting if omitted.',
|
||||
},
|
||||
exaggeration: {
|
||||
type: 'number',
|
||||
description: 'Emotional expressiveness (0.0–1.0). Defaults to 0.5.',
|
||||
},
|
||||
cfg_weight: {
|
||||
type: 'number',
|
||||
description: 'Pacing control (0.0–1.0). Defaults to 0.5.',
|
||||
},
|
||||
},
|
||||
required: ['voice_id'],
|
||||
},
|
||||
},
|
||||
handler: async (args): Promise<ContentBlock[]> => {
|
||||
const body: Record<string, unknown> = { voice_id: args['voice_id'] };
|
||||
if (args['text'] !== undefined) body['text'] = args['text'];
|
||||
if (args['exaggeration'] !== undefined) body['exaggeration'] = args['exaggeration'];
|
||||
if (args['cfg_weight'] !== undefined) body['cfg_weight'] = args['cfg_weight'];
|
||||
|
||||
const result = await rawFetch<{
|
||||
audio_base64: string | null;
|
||||
format: string;
|
||||
sample_rate: number;
|
||||
duration_seconds: number;
|
||||
text_processed: string;
|
||||
voice_id: string | null;
|
||||
}>('/voice-library/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const { audio_base64, ...meta } = result;
|
||||
const lines: string[] = [
|
||||
`format: ${meta.format}`,
|
||||
`sample_rate: ${meta.sample_rate} Hz`,
|
||||
`duration: ${meta.duration_seconds.toFixed(2)}s`,
|
||||
`text_processed: ${meta.text_processed}`,
|
||||
];
|
||||
if (audio_base64) lines.push(`\naudio_base64:\n${audio_base64}`);
|
||||
|
||||
return [{ type: 'text', text: lines.join('\n') }];
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
85
src/tools/service.ts
Normal file
85
src/tools/service.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { spawnSync } from 'child_process';
|
||||
import type { ToolEntry, ContentBlock } from '../types';
|
||||
import { BASE_URL } from '../client';
|
||||
|
||||
const SERVICE = 'chatterbox-tts';
|
||||
|
||||
function isRemote(): boolean {
|
||||
try {
|
||||
const url = new URL(BASE_URL);
|
||||
const host = url.hostname;
|
||||
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function remoteMessage(action: string): ContentBlock[] {
|
||||
return [{
|
||||
type: 'text',
|
||||
text: `Service ${action} unavailable — TTS service is remote at ${BASE_URL}. Manage it on the host directly.`,
|
||||
}];
|
||||
}
|
||||
|
||||
function runSystemctl(action: string, allowNonZero = false): ContentBlock[] {
|
||||
const result = spawnSync('systemctl', ['--user', action, SERVICE], {
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
const output = (result.stdout ?? '') + (result.stderr ?? '');
|
||||
const exitCode = result.status ?? -1;
|
||||
|
||||
if (!allowNonZero && exitCode !== 0) {
|
||||
throw new Error(`systemctl ${action} ${SERVICE} failed (exit ${exitCode}): ${output.trim()}`);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ action, service: SERVICE, exitCode, output: output.trim() }, null, 2),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function serviceTools(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
definition: {
|
||||
name: 'service_start',
|
||||
description: `Start the ${SERVICE} systemd service. Use when the TTS service is stopped or health_check fails with a connection error.`,
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
},
|
||||
handler: async (): Promise<ContentBlock[]> =>
|
||||
isRemote() ? remoteMessage('start') : runSystemctl('start'),
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
name: 'service_stop',
|
||||
description: `Stop the ${SERVICE} systemd service.`,
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
},
|
||||
handler: async (): Promise<ContentBlock[]> =>
|
||||
isRemote() ? remoteMessage('stop') : runSystemctl('stop'),
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
name: 'service_restart',
|
||||
description: `Restart the ${SERVICE} systemd service. Use to recover from errors or apply config changes.`,
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
},
|
||||
handler: async (): Promise<ContentBlock[]> =>
|
||||
isRemote() ? remoteMessage('restart') : runSystemctl('restart'),
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
name: 'service_status',
|
||||
description: `Get the systemd status of the ${SERVICE} service, including active state, PID, recent log lines, and any error messages.`,
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
},
|
||||
// status exits non-zero when inactive — that's informational, not an error
|
||||
handler: async (): Promise<ContentBlock[]> =>
|
||||
isRemote() ? remoteMessage('status') : runSystemctl('status', true),
|
||||
},
|
||||
];
|
||||
}
|
||||
231
src/tools/synthesis.ts
Normal file
231
src/tools/synthesis.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { spawnSync, spawn } from 'child_process';
|
||||
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { tmpdir, homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { rawFetch } from '../client';
|
||||
import type { ToolEntry, ContentBlock } from '../types';
|
||||
import { jsonContent } from '../types';
|
||||
|
||||
const PERSONALITIES_FILE =
|
||||
process.env['SPEECH_PERSONALITIES_FILE'] ??
|
||||
join(homedir(), '.claude', 'speech-personalities.json');
|
||||
|
||||
const NOTIFY_LOCK = join(tmpdir(), 'speech-notify.lock');
|
||||
const IS_MACOS = process.platform === 'darwin';
|
||||
const AUDIO_PLAYER =
|
||||
process.env['AUDIO_PLAYER'] ?? (IS_MACOS ? '/usr/bin/afplay' : '/usr/bin/pw-play');
|
||||
|
||||
interface Personality {
|
||||
voice_id: string | null;
|
||||
exaggeration: number;
|
||||
cfg_weight: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
type PersonalitiesConfig = Record<string, Personality>;
|
||||
|
||||
const DEFAULT_PERSONALITIES: PersonalitiesConfig = {
|
||||
default: {
|
||||
voice_id: null,
|
||||
exaggeration: 0.5,
|
||||
cfg_weight: 0.5,
|
||||
description: 'Calm, neutral delivery',
|
||||
},
|
||||
urgent: {
|
||||
voice_id: null,
|
||||
exaggeration: 0.8,
|
||||
cfg_weight: 0.7,
|
||||
description: 'Energetic, attention-grabbing',
|
||||
},
|
||||
casual: {
|
||||
voice_id: null,
|
||||
exaggeration: 0.3,
|
||||
cfg_weight: 0.4,
|
||||
description: 'Relaxed, low-key',
|
||||
},
|
||||
};
|
||||
|
||||
function loadPersonalities(): PersonalitiesConfig {
|
||||
if (existsSync(PERSONALITIES_FILE)) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(PERSONALITIES_FILE, 'utf8')) as PersonalitiesConfig;
|
||||
} catch {
|
||||
return DEFAULT_PERSONALITIES;
|
||||
}
|
||||
}
|
||||
return DEFAULT_PERSONALITIES;
|
||||
}
|
||||
|
||||
async function rawFetchWithRetry<T>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
maxAttempts: number = 10,
|
||||
delayMs: number = 3000,
|
||||
): Promise<T> {
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await rawFetch<T>(path, options);
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
const isNetworkError = lastError.message.includes('Failed to fetch');
|
||||
if (!isNetworkError || attempt === maxAttempts) throw lastError;
|
||||
if (attempt === 1) {
|
||||
console.error(`[synthesize] TTS service waking up... (waiting up to ${maxAttempts * delayMs / 1000}s)`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('TTS service unavailable');
|
||||
}
|
||||
|
||||
async function submitAndPoll(body: Record<string, unknown>): Promise<{
|
||||
audio_base64: string;
|
||||
format: string;
|
||||
sample_rate: number;
|
||||
duration_seconds: number;
|
||||
text_processed: string;
|
||||
}> {
|
||||
// Submit job — retry on network errors (service waking up)
|
||||
const submitted = await rawFetchWithRetry<{ job_id: string; status: string; queue_position: number }>(
|
||||
'/jobs',
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) },
|
||||
);
|
||||
|
||||
const { job_id: jobId } = submitted;
|
||||
|
||||
// Poll until completed/failed (max 120 × 1s = 2 min)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const poll = await rawFetch<{ status: string; error?: string }>(`/jobs/${jobId}`);
|
||||
if (poll.status === 'failed') throw new Error(poll.error ?? 'Synthesis failed');
|
||||
if (poll.status === 'cancelled') throw new Error('Job was cancelled');
|
||||
if (poll.status === 'completed') break;
|
||||
}
|
||||
|
||||
return rawFetch(`/jobs/${jobId}/result`);
|
||||
}
|
||||
|
||||
export function synthesisTools(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
definition: {
|
||||
name: 'synthesize',
|
||||
description:
|
||||
'Synthesize speech from text using Chatterbox TTS. Plays automatically with cross-session queueing (no overlapping speech). Fire-and-forget: returns immediately while audio plays. Choose a personality to control voice character and emotional delivery.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
text: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The message to speak. Keep it concise — conversational, not a wall of text. Supports tags: [laugh], [cough], [chuckle].',
|
||||
},
|
||||
personality: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Named voice personality to use. Use list_personalities to see options. Defaults to "default".',
|
||||
},
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
},
|
||||
handler: async (args): Promise<ContentBlock[]> => {
|
||||
const personalities = loadPersonalities();
|
||||
const personalityName = (args['personality'] as string | undefined) ?? 'default';
|
||||
const personality = personalities[personalityName] ?? personalities['default'] ?? DEFAULT_PERSONALITIES['default'];
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
text: args['text'],
|
||||
format: 'wav',
|
||||
};
|
||||
if (personality.voice_id) body['voice_id'] = personality.voice_id;
|
||||
if (personality.exaggeration !== undefined) body['exaggeration'] = personality.exaggeration;
|
||||
if (personality.cfg_weight !== undefined) body['cfg_weight'] = personality.cfg_weight;
|
||||
|
||||
const result = await submitAndPoll(body);
|
||||
|
||||
const audioBuffer = Buffer.from(result.audio_base64, 'base64');
|
||||
const tmpFile = join(tmpdir(), `speech-notify-${randomUUID()}.wav`);
|
||||
writeFileSync(tmpFile, audioBuffer);
|
||||
|
||||
// Spawn background process: play audio then cleanup
|
||||
// Linux: flock serializes across sessions to prevent overlapping speech
|
||||
// macOS: afplay blocks until done; flock unavailable but overlap unlikely (5-min nag interval)
|
||||
const playCmd = IS_MACOS
|
||||
? `${AUDIO_PLAYER} ${tmpFile}; rm -f ${tmpFile}`
|
||||
: `flock ${NOTIFY_LOCK} -c "${AUDIO_PLAYER} ${tmpFile}; rm -f ${tmpFile}"`;
|
||||
const shell = spawn(
|
||||
'/bin/bash',
|
||||
['-c', playCmd],
|
||||
{ detached: true, stdio: 'ignore' },
|
||||
);
|
||||
shell.unref();
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
queued: true,
|
||||
personality: personalityName,
|
||||
estimated_duration_seconds: result.duration_seconds,
|
||||
text_processed: result.text_processed,
|
||||
}, null, 2),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
name: 'list_personalities',
|
||||
description:
|
||||
'List available voice personalities for notifications. Each personality has a distinct voice character and emotional delivery. Use these names in the synthesize tool.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
handler: async (): Promise<ContentBlock[]> => {
|
||||
const personalities = loadPersonalities();
|
||||
const output = Object.entries(personalities).map(([name, p]) => ({
|
||||
name,
|
||||
description: p.description,
|
||||
voice_id: p.voice_id ?? '(default voice)',
|
||||
exaggeration: p.exaggeration,
|
||||
cfg_weight: p.cfg_weight,
|
||||
}));
|
||||
return jsonContent(output);
|
||||
},
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
name: 'health_check',
|
||||
description:
|
||||
'Get the health and status of the speech-synthesis service, including whether the TTS model is loaded, GPU/VRAM usage, available voices, and VRAM lease status.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
handler: async (): Promise<ContentBlock[]> => {
|
||||
const result = await rawFetch<unknown>('/health');
|
||||
return jsonContent(result);
|
||||
},
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
name: 'ready_check',
|
||||
description: 'Check whether the TTS model is loaded and ready to synthesize speech.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
handler: async (): Promise<ContentBlock[]> => {
|
||||
const result = await rawFetch<{ ready: boolean }>('/ready');
|
||||
return [{ type: 'text', text: result.ready ? 'Model is loaded and ready.' : 'Model is NOT loaded (idle-stopped). First notify call will wake it — expect ~10s delay.' }];
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
70
src/tools/voices.ts
Normal file
70
src/tools/voices.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { rawFetch } from '../client';
|
||||
import { BASE_URL } from '../client';
|
||||
import type { ToolEntry, ContentBlock } from '../types';
|
||||
import { jsonContent } from '../types';
|
||||
|
||||
export function voiceTools(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
definition: {
|
||||
name: 'list_voices',
|
||||
description: 'List all cloned/saved voices available in the speech-synthesis service.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
handler: async (): Promise<ContentBlock[]> => {
|
||||
const result = await rawFetch<unknown>('/voices');
|
||||
return jsonContent(result);
|
||||
},
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
name: 'get_voice',
|
||||
description: 'Get details for a specific cloned voice by its ID.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
voice_id: {
|
||||
type: 'string',
|
||||
description: 'The unique voice identifier.',
|
||||
},
|
||||
},
|
||||
required: ['voice_id'],
|
||||
},
|
||||
},
|
||||
handler: async (args): Promise<ContentBlock[]> => {
|
||||
const result = await rawFetch<unknown>(`/voices/${encodeURIComponent(args['voice_id'] as string)}`);
|
||||
return jsonContent(result);
|
||||
},
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
name: 'delete_voice',
|
||||
description: 'Delete a cloned voice by its ID. This is irreversible.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
voice_id: {
|
||||
type: 'string',
|
||||
description: 'The unique voice identifier to delete.',
|
||||
},
|
||||
},
|
||||
required: ['voice_id'],
|
||||
},
|
||||
},
|
||||
handler: async (args): Promise<ContentBlock[]> => {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/voices/${encodeURIComponent(args['voice_id'] as string)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (response.status === 204) {
|
||||
return [{ type: 'text', text: `Voice ${args['voice_id']} deleted successfully.` }];
|
||||
}
|
||||
const body = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${body}`);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
24
src/types.ts
Normal file
24
src/types.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export type TextContent = { type: 'text'; text: string };
|
||||
export type ImageContent = { type: 'image'; data: string; mimeType: string };
|
||||
export type ContentBlock = TextContent | ImageContent;
|
||||
|
||||
export type ToolHandler = (args: Record<string, unknown>) => Promise<ContentBlock[]>;
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolEntry {
|
||||
definition: ToolDefinition;
|
||||
handler: ToolHandler;
|
||||
}
|
||||
|
||||
export function jsonContent(data: unknown): ContentBlock[] {
|
||||
return [{ type: 'text', text: JSON.stringify(data, null, 2) }];
|
||||
}
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"@modelcontextprotocol/sdk/server": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/server/index.d.ts"],
|
||||
"@modelcontextprotocol/sdk/server/stdio": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.d.ts"],
|
||||
"@modelcontextprotocol/sdk/types": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/types.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
48
tsup.config.ts
Normal file
48
tsup.config.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
target: 'es2022',
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
dts: true,
|
||||
bundle: true,
|
||||
noExternal: [/.*/],
|
||||
banner: { js: '#!/usr/bin/env node' },
|
||||
esbuildPlugins: [
|
||||
{
|
||||
name: 'fix-mcp-sdk-deps',
|
||||
setup(build) {
|
||||
const explicitExports = new Set(['server', 'client', 'validation', 'experimental']);
|
||||
build.onResolve({ filter: /^@modelcontextprotocol\/sdk\/.+/ }, (args) => {
|
||||
const subpath = args.path.replace('@modelcontextprotocol/sdk/', '');
|
||||
const topLevel = subpath.split('/')[0];
|
||||
if (explicitExports.has(topLevel) && !subpath.includes('/')) return undefined;
|
||||
return {
|
||||
path: resolve(
|
||||
__dirname,
|
||||
'node_modules/@modelcontextprotocol/sdk/dist/esm',
|
||||
subpath + '.js',
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
build.onResolve({ filter: /^ajv-formats/ }, (args) => {
|
||||
return { path: args.path, namespace: 'ajv-stub' };
|
||||
});
|
||||
build.onResolve({ filter: /^ajv\/dist\// }, (args) => {
|
||||
return { path: args.path, namespace: 'ajv-stub' };
|
||||
});
|
||||
build.onLoad({ filter: /.*/, namespace: 'ajv-stub' }, () => {
|
||||
return { contents: 'module.exports = function() {};', loader: 'js' };
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue