#![allow(deprecated)]
use anyhow::Result;
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::{ErrorData, ServerCapabilities, ServerInfo};
use rmcp::{ServerHandler, ServiceExt, schemars, tool, tool_handler, tool_router};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::Path;
use polyvoice::models::ModelRegistry;
use polyvoice::pipeline::Pipeline as LegacyPipeline;
use polyvoice::types::{ClusterConfig, DiarizationConfig, DiarizationResult, Profile, SampleRate};
use polyvoice::vad::VadConfig;
use polyvoice::wav::read_wav;
use polyvoice::{FbankOnnxExtractor, SileroVad};
const ERR_INVALID_ARG: i32 = 1;
const ERR_MODEL_LOAD: i32 = 10;
const ERR_INFERENCE: i32 = 11;
const ERR_REGISTRY: i32 = 30;
const ERR_INTERNAL: i32 = 99;
fn err(code: i32, message: impl Into<String>) -> ErrorData {
let message = message.into();
ErrorData::invalid_params(
message.clone(),
Some(serde_json::json!({ "code": code, "message": message })),
)
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct DiarizeInput {
path: String,
#[serde(default)]
profile: Option<String>,
#[serde(default)]
threshold: Option<f32>,
#[serde(default)]
max_speakers: Option<usize>,
#[serde(default)]
verbosity: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[allow(dead_code)] struct TranscribeInput {
path: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct SpeakerRollup {
label: String,
id: u32,
total_speech_s: f64,
turn_count: usize,
}
#[derive(Debug, Serialize, JsonSchema)]
struct TurnDto {
speaker: String,
speaker_id: u32,
start: f64,
end: f64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct DiarizeOutput {
schema_version: String,
num_speakers: usize,
duration_s: f64,
speakers: Vec<SpeakerRollup>,
#[serde(skip_serializing_if = "Option::is_none")]
turns: Option<Vec<TurnDto>>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct Capabilities {
name: String,
version: String,
tools: Vec<String>,
asr_available: bool,
output_formats: Vec<String>,
profiles: Vec<String>,
}
#[derive(Clone)]
struct PolyvoiceMcp;
#[tool_router]
impl PolyvoiceMcp {
fn new() -> Self {
Self
}
#[tool(
name = "polyvoice.capabilities",
description = "List the tools, version, ASR availability, and output formats of this server."
)]
fn capabilities(&self) -> Json<Capabilities> {
Json(Capabilities {
name: "polyvoice-mcp".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
tools: vec![
"polyvoice.diarize".to_owned(),
"polyvoice.transcribe".to_owned(),
"polyvoice.diarize_and_transcribe".to_owned(),
"polyvoice.capabilities".to_owned(),
],
asr_available: false,
output_formats: vec![
"rttm".to_owned(),
"json".to_owned(),
"srt".to_owned(),
"vtt".to_owned(),
"txt".to_owned(),
],
profiles: vec!["balanced".to_owned(), "mobile".to_owned()],
})
}
#[tool(
name = "polyvoice.diarize",
description = "Diarize a WAV file (who spoke when). Returns the canonical DiarizationResult v1 (concise rollup, or full turns with verbosity=detailed)."
)]
fn diarize(
&self,
Parameters(input): Parameters<DiarizeInput>,
) -> Result<Json<DiarizeOutput>, ErrorData> {
let result = run_diarize(&input)?;
let detailed = input.verbosity.as_deref() == Some("detailed");
Ok(Json(project(&result, detailed)))
}
#[tool(
name = "polyvoice.transcribe",
description = "Transcribe a WAV file. Requires the optional polyvoice-asr crate, which is not installed."
)]
fn transcribe(
&self,
Parameters(_input): Parameters<TranscribeInput>,
) -> Result<Json<DiarizeOutput>, ErrorData> {
Err(asr_unavailable())
}
#[tool(
name = "polyvoice.diarize_and_transcribe",
description = "Diarize + transcribe (who said what). Requires the optional polyvoice-asr crate, which is not installed."
)]
fn diarize_and_transcribe(
&self,
Parameters(_input): Parameters<DiarizeInput>,
) -> Result<Json<DiarizeOutput>, ErrorData> {
Err(asr_unavailable())
}
}
#[tool_handler]
impl ServerHandler for PolyvoiceMcp {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.capabilities = ServerCapabilities::builder().enable_tools().build();
info.instructions = Some(
"polyvoice speaker diarization. Call polyvoice.diarize with a WAV path to get \
who-spoke-when; polyvoice.capabilities to discover features. Transcription tools \
require the optional polyvoice-asr crate."
.to_owned(),
);
info
}
}
fn asr_unavailable() -> ErrorData {
err(
ERR_INTERNAL,
"ASR is unavailable: install the optional `polyvoice-asr` companion crate to enable transcription",
)
}
fn project(result: &DiarizationResult, detailed: bool) -> DiarizeOutput {
let speakers = result
.speakers
.iter()
.map(|s| SpeakerRollup {
label: s.label.clone(),
id: s.id,
total_speech_s: s.total_speech_s,
turn_count: s.turn_count,
})
.collect();
let turns = detailed.then(|| {
result
.turns
.iter()
.map(|t| TurnDto {
speaker: t.speaker.to_string(),
speaker_id: t.speaker.0,
start: t.time.start,
end: t.time.end,
})
.collect()
});
DiarizeOutput {
schema_version: result.schema_version.clone(),
num_speakers: result.num_speakers,
duration_s: result.audio.duration_secs,
speakers,
turns,
}
}
fn run_diarize(input: &DiarizeInput) -> Result<DiarizationResult, ErrorData> {
let path = Path::new(&input.path);
if !path.is_file() {
return Err(err(
ERR_INVALID_ARG,
format!("no such file: {}", input.path),
));
}
let profile = match input.profile.as_deref().unwrap_or("balanced") {
"balanced" => Profile::Balanced,
"mobile" => Profile::Mobile,
other => {
return Err(err(
ERR_INVALID_ARG,
format!("invalid profile: {other} (expected balanced|mobile)"),
));
}
};
let registry = ModelRegistry::default().map_err(|e| err(ERR_REGISTRY, e.to_string()))?;
let models = registry
.ensure_for_profile(profile)
.map_err(|e| err(ERR_MODEL_LOAD, e.to_string()))?;
let extractor = FbankOnnxExtractor::new(&models.embedder_path, profile.embedding_dim(), 1)
.map_err(|e| err(ERR_MODEL_LOAD, e.to_string()))?;
let vad_path = registry
.ensure("silero_vad")
.map_err(|e| err(ERR_MODEL_LOAD, e.to_string()))?;
let mut vad = SileroVad::new(&vad_path, 512).map_err(|e| err(ERR_MODEL_LOAD, e.to_string()))?;
let mut cluster = ClusterConfig {
threshold: input.threshold.unwrap_or(0.45),
..Default::default()
};
if let Some(n) = input.max_speakers {
cluster.max_speakers = n;
}
let config = DiarizationConfig {
cluster,
..DiarizationConfig::default()
};
let pipeline = LegacyPipeline::new(config, VadConfig::default());
let (samples, sr_hz) = read_wav(path).map_err(|e| err(ERR_INVALID_ARG, e.to_string()))?;
SampleRate::new(sr_hz)
.ok_or_else(|| err(ERR_INVALID_ARG, format!("invalid sample rate {sr_hz} Hz")))?;
pipeline
.run(&samples, &extractor, &mut vad)
.map_err(|e| err(ERR_INFERENCE, e.to_string()))
}
#[tokio::main]
async fn main() -> Result<()> {
let service = PolyvoiceMcp::new()
.serve(rmcp::transport::io::stdio())
.await?;
service.waiting().await?;
Ok(())
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capabilities_lists_four_tools_and_no_asr() {
let cap = PolyvoiceMcp::new().capabilities().0;
assert_eq!(cap.tools.len(), 4);
assert!(!cap.asr_available);
assert!(cap.tools.iter().any(|t| t == "polyvoice.diarize"));
assert_eq!(cap.output_formats.len(), 5);
}
#[test]
fn asr_unavailable_error_carries_ffi_code() {
let e = asr_unavailable();
let data = e.data.expect("data");
assert_eq!(data["code"], ERR_INTERNAL);
assert!(data["message"].as_str().unwrap().contains("polyvoice-asr"));
}
#[test]
fn input_schema_is_strict() {
let schema = schemars::schema_for!(DiarizeInput);
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(json["additionalProperties"], serde_json::json!(false));
assert!(json["properties"]["path"].is_object());
}
}