use crate::error::{CliError, Result};
use std::collections::HashMap;
use voirs_sdk::config::AppConfig;
use voirs_sdk::types::VoiceConfig;
use voirs_sdk::{QualityLevel, Result as VoirsResult, VoirsPipeline};
pub struct VoiceSearch {
voices: Vec<VoiceConfig>,
}
impl VoiceSearch {
pub async fn new(config: &AppConfig) -> Result<Self> {
let pipeline = VoirsPipeline::builder().build().await?;
let voices = pipeline.list_voices().await?;
Ok(Self { voices })
}
pub fn search(&self, query: &str) -> Vec<VoiceSearchResult> {
let query_lower = query.to_lowercase();
let terms: Vec<&str> = query_lower.split_whitespace().collect();
let mut results = Vec::new();
for voice in &self.voices {
let score = self.calculate_relevance_score(voice, &terms);
if score > 0.0 {
results.push(VoiceSearchResult {
voice: voice.clone(),
relevance_score: score,
match_reasons: self.get_match_reasons(voice, &terms),
});
}
}
results.sort_by(|a, b| {
b.relevance_score
.partial_cmp(&a.relevance_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results
}
pub fn search_by_criteria(&self, criteria: &VoiceSearchCriteria) -> Vec<VoiceSearchResult> {
let mut results = Vec::new();
for voice in &self.voices {
let mut score = 1.0;
let mut reasons = Vec::new();
let mut matches = true;
if let Some(ref language) = criteria.language {
if voice.language.as_str().to_lowercase() != language.to_lowercase() {
matches = false;
} else {
reasons.push("Matching language".to_string());
}
}
if let Some(ref gender) = criteria.gender {
if let Some(voice_gender) = &voice.characteristics.gender {
if format!("{:?}", voice_gender).to_lowercase() != gender.to_lowercase() {
matches = false;
} else {
reasons.push("Matching gender".to_string());
score += 0.5;
}
} else if criteria.require_gender {
matches = false;
}
}
if let Some(ref age) = criteria.age {
if let Some(voice_age) = &voice.characteristics.age {
if format!("{:?}", voice_age).to_lowercase() != age.to_lowercase() {
matches = false;
} else {
reasons.push("Matching age".to_string());
score += 0.3;
}
} else if criteria.require_age {
matches = false;
}
}
if let Some(ref style) = criteria.style {
if format!("{:?}", voice.characteristics.style).to_lowercase()
!= style.to_lowercase()
{
matches = false;
} else {
reasons.push("Matching style".to_string());
score += 0.4;
}
}
if let Some(ref quality) = criteria.min_quality {
let voice_quality_score = match voice.characteristics.quality {
QualityLevel::Low => 1,
QualityLevel::Medium => 2,
QualityLevel::High => 3,
QualityLevel::Ultra => 4,
};
let min_quality_score = match quality.as_str() {
"low" => 1,
"medium" => 2,
"high" => 3,
"ultra" => 4,
_ => 1,
};
if voice_quality_score < min_quality_score {
matches = false;
} else if voice_quality_score >= min_quality_score {
reasons.push("Meets quality requirements".to_string());
score += 0.2;
}
}
if criteria.emotion_support {
if voice.characteristics.emotion_support {
reasons.push("Supports emotions".to_string());
score += 0.3;
} else if criteria.require_emotion_support {
matches = false;
}
}
if matches {
results.push(VoiceSearchResult {
voice: voice.clone(),
relevance_score: score,
match_reasons: reasons,
});
}
}
results.sort_by(|a, b| {
b.relevance_score
.partial_cmp(&a.relevance_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results
}
pub fn recommend_for_text(&self, text: &str) -> Vec<VoiceSearchResult> {
let mut criteria = VoiceSearchCriteria::default();
let word_count = text.split_whitespace().count();
let has_questions = text.contains('?');
let has_exclamations = text.contains('!');
let is_formal = self.is_formal_text(text);
if has_exclamations || text.to_uppercase() == text {
criteria.style = Some("Energetic".to_string());
} else if has_questions || is_formal {
criteria.style = Some("Professional".to_string());
} else if word_count > 100 {
criteria.style = Some("Narrative".to_string());
}
if word_count > 50 {
criteria.min_quality = Some("high".to_string());
} else {
criteria.min_quality = Some("medium".to_string());
}
self.search_by_criteria(&criteria)
}
fn calculate_relevance_score(&self, voice: &VoiceConfig, terms: &[&str]) -> f32 {
let mut score = 0.0;
for term in terms {
if voice.id.to_lowercase().contains(term) {
score += 3.0;
}
if voice.name.to_lowercase().contains(term) {
score += 2.5;
}
if voice.language.as_str().to_lowercase().contains(term) {
score += 2.0;
}
if let Some(description) = voice.metadata.get("description") {
if description.to_lowercase().contains(term) {
score += 1.5;
}
}
if let Some(gender) = &voice.characteristics.gender {
if format!("{:?}", gender).to_lowercase().contains(term) {
score += 1.0;
}
}
if let Some(age) = &voice.characteristics.age {
if format!("{:?}", age).to_lowercase().contains(term) {
score += 1.0;
}
}
if format!("{:?}", voice.characteristics.style)
.to_lowercase()
.contains(term)
{
score += 1.0;
}
for (key, value) in &voice.metadata {
if key.to_lowercase().contains(term) || value.to_lowercase().contains(term) {
score += 0.5;
}
}
}
score
}
fn get_match_reasons(&self, voice: &VoiceConfig, terms: &[&str]) -> Vec<String> {
let mut reasons = Vec::new();
for term in terms {
if voice.id.to_lowercase().contains(term) {
reasons.push(format!("ID contains '{}'", term));
}
if voice.name.to_lowercase().contains(term) {
reasons.push(format!("Name contains '{}'", term));
}
if voice.language.as_str().to_lowercase().contains(term) {
reasons.push(format!("Language matches '{}'", term));
}
if let Some(description) = voice.metadata.get("description") {
if description.to_lowercase().contains(term) {
reasons.push(format!("Description contains '{}'", term));
}
}
}
if reasons.is_empty() {
reasons.push("General match".to_string());
}
reasons
}
fn is_formal_text(&self, text: &str) -> bool {
let formal_indicators = [
"please",
"thank you",
"regarding",
"furthermore",
"however",
"therefore",
"moreover",
"nevertheless",
"consequently",
"accordingly",
];
let text_lower = text.to_lowercase();
formal_indicators
.iter()
.any(|&indicator| text_lower.contains(indicator))
}
pub fn get_statistics(&self) -> VoiceStatistics {
let mut stats = VoiceStatistics::default();
stats.total_voices = self.voices.len();
for voice in &self.voices {
let lang = voice.language.as_str();
*stats.languages.entry(lang.to_string()).or_insert(0) += 1;
}
for voice in &self.voices {
if let Some(gender) = &voice.characteristics.gender {
let gender_str = format!("{:?}", gender);
*stats.genders.entry(gender_str).or_insert(0) += 1;
} else {
*stats.genders.entry("Unknown".to_string()).or_insert(0) += 1;
}
}
for voice in &self.voices {
let quality_str = format!("{:?}", voice.characteristics.quality);
*stats.qualities.entry(quality_str).or_insert(0) += 1;
}
stats.emotion_support_count = self
.voices
.iter()
.filter(|v| v.characteristics.emotion_support)
.count();
stats
}
}
#[derive(Debug, Default)]
pub struct VoiceSearchCriteria {
pub language: Option<String>,
pub gender: Option<String>,
pub age: Option<String>,
pub style: Option<String>,
pub min_quality: Option<String>,
pub emotion_support: bool,
pub require_gender: bool,
pub require_age: bool,
pub require_emotion_support: bool,
}
#[derive(Debug, Clone)]
pub struct VoiceSearchResult {
pub voice: VoiceConfig,
pub relevance_score: f32,
pub match_reasons: Vec<String>,
}
#[derive(Debug, Default)]
pub struct VoiceStatistics {
pub total_voices: usize,
pub languages: HashMap<String, usize>,
pub genders: HashMap<String, usize>,
pub qualities: HashMap<String, usize>,
pub emotion_support_count: usize,
}
#[derive(Debug)]
pub struct VoiceSearchCommandConfig<'a> {
pub query: Option<&'a str>,
pub language: Option<&'a str>,
pub gender: Option<&'a str>,
pub age: Option<&'a str>,
pub style: Option<&'a str>,
pub min_quality: Option<&'a str>,
pub emotion_support: bool,
pub show_stats: bool,
}
pub async fn run_voice_search(
search_config: VoiceSearchCommandConfig<'_>,
config: &AppConfig,
) -> Result<()> {
let search = VoiceSearch::new(config).await?;
if search_config.show_stats {
print_voice_statistics(&search.get_statistics());
return Ok(());
}
let results = if let Some(query) = search_config.query {
search.search(query)
} else {
let criteria = VoiceSearchCriteria {
language: search_config.language.map(|s| s.to_string()),
gender: search_config.gender.map(|s| s.to_string()),
age: search_config.age.map(|s| s.to_string()),
style: search_config.style.map(|s| s.to_string()),
min_quality: search_config.min_quality.map(|s| s.to_string()),
emotion_support: search_config.emotion_support,
require_gender: search_config.gender.is_some(),
require_age: search_config.age.is_some(),
require_emotion_support: search_config.emotion_support,
};
search.search_by_criteria(&criteria)
};
if results.is_empty() {
println!("No voices found matching your criteria.");
if let Some(query) = search_config.query {
println!("Try using broader search terms or check the available voices with 'voirs voices list'.");
}
return Ok(());
}
println!("Found {} voice(s):", results.len());
println!();
for (i, result) in results.iter().enumerate().take(10) {
print_voice_search_result(i + 1, result);
if i < results.len() - 1 {
println!("---");
}
}
if results.len() > 10 {
println!();
println!(
"... and {} more results. Use more specific search criteria to narrow down.",
results.len() - 10
);
}
Ok(())
}
fn print_voice_search_result(index: usize, result: &VoiceSearchResult) {
println!("{}. {} ({})", index, result.voice.name, result.voice.id);
println!(" Language: {}", result.voice.language.as_str());
if let Some(gender) = &result.voice.characteristics.gender {
println!(" Gender: {:?}", gender);
}
if let Some(age) = &result.voice.characteristics.age {
println!(" Age: {:?}", age);
}
println!(" Style: {:?}", result.voice.characteristics.style);
println!(" Quality: {:?}", result.voice.characteristics.quality);
if result.voice.characteristics.emotion_support {
println!(" ✓ Emotion support");
}
println!(" Relevance: {:.1}", result.relevance_score);
println!(" Matches: {}", result.match_reasons.join(", "));
if let Some(description) = result.voice.metadata.get("description") {
if description.len() <= 100 {
println!(" Description: {}", description);
} else {
println!(" Description: {}...", &description[..97]);
}
}
}
fn print_voice_statistics(stats: &VoiceStatistics) {
println!("Voice Database Statistics");
println!("========================");
println!("Total voices: {}", stats.total_voices);
println!();
println!("By Language:");
let mut lang_vec: Vec<_> = stats.languages.iter().collect();
lang_vec.sort_by_key(|(_, count)| **count);
lang_vec.reverse();
for (lang, count) in lang_vec {
println!(" {}: {}", lang, count);
}
println!();
println!("By Gender:");
let mut gender_vec: Vec<_> = stats.genders.iter().collect();
gender_vec.sort_by_key(|(_, count)| **count);
gender_vec.reverse();
for (gender, count) in gender_vec {
println!(" {}: {}", gender, count);
}
println!();
println!("By Quality:");
let mut quality_vec: Vec<_> = stats.qualities.iter().collect();
quality_vec.sort_by_key(|(_, count)| **count);
quality_vec.reverse();
for (quality, count) in quality_vec {
println!(" {}: {}", quality, count);
}
println!();
println!(
"Emotion Support: {} voices ({:.1}%)",
stats.emotion_support_count,
(stats.emotion_support_count as f32 / stats.total_voices as f32) * 100.0
);
}