Skip to main content

voirs_cli/commands/
voice_search.rs

1//! Voice search functionality.
2
3use crate::error::{CliError, Result};
4use std::collections::HashMap;
5use voirs_sdk::config::AppConfig;
6use voirs_sdk::types::VoiceConfig;
7use voirs_sdk::{QualityLevel, Result as VoirsResult, VoirsPipeline};
8
9/// Voice search functionality
10pub struct VoiceSearch {
11    /// Available voices
12    voices: Vec<VoiceConfig>,
13}
14
15impl VoiceSearch {
16    /// Create a new voice search instance
17    pub async fn new(config: &AppConfig) -> Result<Self> {
18        let pipeline = VoirsPipeline::builder().build().await?;
19        let voices = pipeline.list_voices().await?;
20
21        Ok(Self { voices })
22    }
23
24    /// Search voices by query string
25    pub fn search(&self, query: &str) -> Vec<VoiceSearchResult> {
26        let query_lower = query.to_lowercase();
27        let terms: Vec<&str> = query_lower.split_whitespace().collect();
28
29        let mut results = Vec::new();
30
31        for voice in &self.voices {
32            let score = self.calculate_relevance_score(voice, &terms);
33            if score > 0.0 {
34                results.push(VoiceSearchResult {
35                    voice: voice.clone(),
36                    relevance_score: score,
37                    match_reasons: self.get_match_reasons(voice, &terms),
38                });
39            }
40        }
41
42        // Sort by relevance score (highest first)
43        results.sort_by(|a, b| b.relevance_score.partial_cmp(&a.relevance_score).unwrap());
44
45        results
46    }
47
48    /// Search voices by specific criteria
49    pub fn search_by_criteria(&self, criteria: &VoiceSearchCriteria) -> Vec<VoiceSearchResult> {
50        let mut results = Vec::new();
51
52        for voice in &self.voices {
53            let mut score = 1.0;
54            let mut reasons = Vec::new();
55            let mut matches = true;
56
57            // Language filter
58            if let Some(ref language) = criteria.language {
59                if voice.language.as_str().to_lowercase() != language.to_lowercase() {
60                    matches = false;
61                } else {
62                    reasons.push("Matching language".to_string());
63                }
64            }
65
66            // Gender filter
67            if let Some(ref gender) = criteria.gender {
68                if let Some(voice_gender) = &voice.characteristics.gender {
69                    if format!("{:?}", voice_gender).to_lowercase() != gender.to_lowercase() {
70                        matches = false;
71                    } else {
72                        reasons.push("Matching gender".to_string());
73                        score += 0.5;
74                    }
75                } else if criteria.require_gender {
76                    matches = false;
77                }
78            }
79
80            // Age filter
81            if let Some(ref age) = criteria.age {
82                if let Some(voice_age) = &voice.characteristics.age {
83                    if format!("{:?}", voice_age).to_lowercase() != age.to_lowercase() {
84                        matches = false;
85                    } else {
86                        reasons.push("Matching age".to_string());
87                        score += 0.3;
88                    }
89                } else if criteria.require_age {
90                    matches = false;
91                }
92            }
93
94            // Style filter
95            if let Some(ref style) = criteria.style {
96                if format!("{:?}", voice.characteristics.style).to_lowercase()
97                    != style.to_lowercase()
98                {
99                    matches = false;
100                } else {
101                    reasons.push("Matching style".to_string());
102                    score += 0.4;
103                }
104            }
105
106            // Quality filter
107            if let Some(ref quality) = criteria.min_quality {
108                let voice_quality_score = match voice.characteristics.quality {
109                    QualityLevel::Low => 1,
110                    QualityLevel::Medium => 2,
111                    QualityLevel::High => 3,
112                    QualityLevel::Ultra => 4,
113                };
114                let min_quality_score = match quality.as_str() {
115                    "low" => 1,
116                    "medium" => 2,
117                    "high" => 3,
118                    "ultra" => 4,
119                    _ => 1,
120                };
121
122                if voice_quality_score < min_quality_score {
123                    matches = false;
124                } else if voice_quality_score >= min_quality_score {
125                    reasons.push("Meets quality requirements".to_string());
126                    score += 0.2;
127                }
128            }
129
130            // Emotion support filter
131            if criteria.emotion_support {
132                if voice.characteristics.emotion_support {
133                    reasons.push("Supports emotions".to_string());
134                    score += 0.3;
135                } else if criteria.require_emotion_support {
136                    matches = false;
137                }
138            }
139
140            if matches {
141                results.push(VoiceSearchResult {
142                    voice: voice.clone(),
143                    relevance_score: score,
144                    match_reasons: reasons,
145                });
146            }
147        }
148
149        // Sort by relevance score
150        results.sort_by(|a, b| b.relevance_score.partial_cmp(&a.relevance_score).unwrap());
151
152        results
153    }
154
155    /// Get voice recommendations based on text content
156    pub fn recommend_for_text(&self, text: &str) -> Vec<VoiceSearchResult> {
157        let mut criteria = VoiceSearchCriteria::default();
158
159        // Analyze text to suggest appropriate voice characteristics
160        let word_count = text.split_whitespace().count();
161        let has_questions = text.contains('?');
162        let has_exclamations = text.contains('!');
163        let is_formal = self.is_formal_text(text);
164
165        // Suggest style based on content
166        if has_exclamations || text.to_uppercase() == text {
167            criteria.style = Some("Energetic".to_string());
168        } else if has_questions || is_formal {
169            criteria.style = Some("Professional".to_string());
170        } else if word_count > 100 {
171            criteria.style = Some("Narrative".to_string());
172        }
173
174        // Suggest quality based on text length
175        if word_count > 50 {
176            criteria.min_quality = Some("high".to_string());
177        } else {
178            criteria.min_quality = Some("medium".to_string());
179        }
180
181        self.search_by_criteria(&criteria)
182    }
183
184    /// Calculate relevance score for a voice given search terms
185    fn calculate_relevance_score(&self, voice: &VoiceConfig, terms: &[&str]) -> f32 {
186        let mut score = 0.0;
187
188        for term in terms {
189            // Check voice ID (highest weight)
190            if voice.id.to_lowercase().contains(term) {
191                score += 3.0;
192            }
193
194            // Check voice name
195            if voice.name.to_lowercase().contains(term) {
196                score += 2.5;
197            }
198
199            // Check language
200            if voice.language.as_str().to_lowercase().contains(term) {
201                score += 2.0;
202            }
203
204            // Check description in metadata
205            if let Some(description) = voice.metadata.get("description") {
206                if description.to_lowercase().contains(term) {
207                    score += 1.5;
208                }
209            }
210
211            // Check characteristics
212            if let Some(gender) = &voice.characteristics.gender {
213                if format!("{:?}", gender).to_lowercase().contains(term) {
214                    score += 1.0;
215                }
216            }
217
218            if let Some(age) = &voice.characteristics.age {
219                if format!("{:?}", age).to_lowercase().contains(term) {
220                    score += 1.0;
221                }
222            }
223
224            if format!("{:?}", voice.characteristics.style)
225                .to_lowercase()
226                .contains(term)
227            {
228                score += 1.0;
229            }
230
231            // Check metadata tags
232            for (key, value) in &voice.metadata {
233                if key.to_lowercase().contains(term) || value.to_lowercase().contains(term) {
234                    score += 0.5;
235                }
236            }
237        }
238
239        score
240    }
241
242    /// Get reasons why a voice matched the search terms
243    fn get_match_reasons(&self, voice: &VoiceConfig, terms: &[&str]) -> Vec<String> {
244        let mut reasons = Vec::new();
245
246        for term in terms {
247            if voice.id.to_lowercase().contains(term) {
248                reasons.push(format!("ID contains '{}'", term));
249            }
250            if voice.name.to_lowercase().contains(term) {
251                reasons.push(format!("Name contains '{}'", term));
252            }
253            if voice.language.as_str().to_lowercase().contains(term) {
254                reasons.push(format!("Language matches '{}'", term));
255            }
256            if let Some(description) = voice.metadata.get("description") {
257                if description.to_lowercase().contains(term) {
258                    reasons.push(format!("Description contains '{}'", term));
259                }
260            }
261        }
262
263        if reasons.is_empty() {
264            reasons.push("General match".to_string());
265        }
266
267        reasons
268    }
269
270    /// Check if text appears to be formal
271    fn is_formal_text(&self, text: &str) -> bool {
272        let formal_indicators = [
273            "please",
274            "thank you",
275            "regarding",
276            "furthermore",
277            "however",
278            "therefore",
279            "moreover",
280            "nevertheless",
281            "consequently",
282            "accordingly",
283        ];
284
285        let text_lower = text.to_lowercase();
286        formal_indicators
287            .iter()
288            .any(|&indicator| text_lower.contains(indicator))
289    }
290
291    /// Get voice statistics
292    pub fn get_statistics(&self) -> VoiceStatistics {
293        let mut stats = VoiceStatistics::default();
294
295        stats.total_voices = self.voices.len();
296
297        // Language distribution
298        for voice in &self.voices {
299            let lang = voice.language.as_str();
300            *stats.languages.entry(lang.to_string()).or_insert(0) += 1;
301        }
302
303        // Gender distribution
304        for voice in &self.voices {
305            if let Some(gender) = &voice.characteristics.gender {
306                let gender_str = format!("{:?}", gender);
307                *stats.genders.entry(gender_str).or_insert(0) += 1;
308            } else {
309                *stats.genders.entry("Unknown".to_string()).or_insert(0) += 1;
310            }
311        }
312
313        // Quality distribution
314        for voice in &self.voices {
315            let quality_str = format!("{:?}", voice.characteristics.quality);
316            *stats.qualities.entry(quality_str).or_insert(0) += 1;
317        }
318
319        // Emotion support
320        stats.emotion_support_count = self
321            .voices
322            .iter()
323            .filter(|v| v.characteristics.emotion_support)
324            .count();
325
326        stats
327    }
328}
329
330/// Voice search criteria
331#[derive(Debug, Default)]
332pub struct VoiceSearchCriteria {
333    pub language: Option<String>,
334    pub gender: Option<String>,
335    pub age: Option<String>,
336    pub style: Option<String>,
337    pub min_quality: Option<String>,
338    pub emotion_support: bool,
339    pub require_gender: bool,
340    pub require_age: bool,
341    pub require_emotion_support: bool,
342}
343
344/// Voice search result
345#[derive(Debug, Clone)]
346pub struct VoiceSearchResult {
347    pub voice: VoiceConfig,
348    pub relevance_score: f32,
349    pub match_reasons: Vec<String>,
350}
351
352/// Voice statistics
353#[derive(Debug, Default)]
354pub struct VoiceStatistics {
355    pub total_voices: usize,
356    pub languages: HashMap<String, usize>,
357    pub genders: HashMap<String, usize>,
358    pub qualities: HashMap<String, usize>,
359    pub emotion_support_count: usize,
360}
361
362/// Configuration for voice search command
363///
364/// Consolidates all search parameters for finding voices in the VoiRS voice
365/// registry. Supports text-based search, filtering by characteristics, and
366/// voice statistics display.
367///
368/// # Examples
369///
370/// Basic text search:
371/// ```no_run
372/// use voirs_cli::commands::voice_search::VoiceSearchCommandConfig;
373///
374/// let config = VoiceSearchCommandConfig {
375///     query: Some("friendly female voice"),
376///     language: None,
377///     gender: None,
378///     age: None,
379///     style: None,
380///     min_quality: None,
381///     emotion_support: false,
382///     show_stats: false,
383/// };
384/// ```
385///
386/// Filtered search:
387/// ```no_run
388/// use voirs_cli::commands::voice_search::VoiceSearchCommandConfig;
389///
390/// let config = VoiceSearchCommandConfig {
391///     query: Some("professional"),
392///     language: Some("en-US"),
393///     gender: Some("female"),
394///     age: Some("young-adult"),
395///     style: Some("formal"),
396///     min_quality: Some("high"),
397///     emotion_support: true,
398///     show_stats: false,
399/// };
400/// ```
401#[derive(Debug)]
402pub struct VoiceSearchCommandConfig<'a> {
403    /// Optional search query text (searches in name, description, tags)
404    pub query: Option<&'a str>,
405    /// Filter by language code (e.g., "en-US", "ja-JP")
406    pub language: Option<&'a str>,
407    /// Filter by gender ("male", "female", "neutral")
408    pub gender: Option<&'a str>,
409    /// Filter by age range ("child", "young-adult", "adult", "senior")
410    pub age: Option<&'a str>,
411    /// Filter by voice style (e.g., "casual", "formal", "energetic")
412    pub style: Option<&'a str>,
413    /// Minimum quality level ("low", "medium", "high", "ultra")
414    pub min_quality: Option<&'a str>,
415    /// Filter to only voices with emotion support
416    pub emotion_support: bool,
417    /// Show voice statistics instead of search results
418    pub show_stats: bool,
419}
420
421/// Run voice search command
422pub async fn run_voice_search(
423    search_config: VoiceSearchCommandConfig<'_>,
424    config: &AppConfig,
425) -> Result<()> {
426    let search = VoiceSearch::new(config).await?;
427
428    if search_config.show_stats {
429        print_voice_statistics(&search.get_statistics());
430        return Ok(());
431    }
432
433    let results = if let Some(query) = search_config.query {
434        search.search(query)
435    } else {
436        let criteria = VoiceSearchCriteria {
437            language: search_config.language.map(|s| s.to_string()),
438            gender: search_config.gender.map(|s| s.to_string()),
439            age: search_config.age.map(|s| s.to_string()),
440            style: search_config.style.map(|s| s.to_string()),
441            min_quality: search_config.min_quality.map(|s| s.to_string()),
442            emotion_support: search_config.emotion_support,
443            require_gender: search_config.gender.is_some(),
444            require_age: search_config.age.is_some(),
445            require_emotion_support: search_config.emotion_support,
446        };
447        search.search_by_criteria(&criteria)
448    };
449
450    if results.is_empty() {
451        println!("No voices found matching your criteria.");
452        if let Some(query) = search_config.query {
453            println!("Try using broader search terms or check the available voices with 'voirs voices list'.");
454        }
455        return Ok(());
456    }
457
458    println!("Found {} voice(s):", results.len());
459    println!();
460
461    for (i, result) in results.iter().enumerate().take(10) {
462        // Limit to top 10 results
463        print_voice_search_result(i + 1, result);
464        if i < results.len() - 1 {
465            println!("---");
466        }
467    }
468
469    if results.len() > 10 {
470        println!();
471        println!(
472            "... and {} more results. Use more specific search criteria to narrow down.",
473            results.len() - 10
474        );
475    }
476
477    Ok(())
478}
479
480/// Print voice search result
481fn print_voice_search_result(index: usize, result: &VoiceSearchResult) {
482    println!("{}. {} ({})", index, result.voice.name, result.voice.id);
483    println!("   Language: {}", result.voice.language.as_str());
484
485    if let Some(gender) = &result.voice.characteristics.gender {
486        println!("   Gender: {:?}", gender);
487    }
488
489    if let Some(age) = &result.voice.characteristics.age {
490        println!("   Age: {:?}", age);
491    }
492
493    println!("   Style: {:?}", result.voice.characteristics.style);
494    println!("   Quality: {:?}", result.voice.characteristics.quality);
495
496    if result.voice.characteristics.emotion_support {
497        println!("   ✓ Emotion support");
498    }
499
500    println!("   Relevance: {:.1}", result.relevance_score);
501    println!("   Matches: {}", result.match_reasons.join(", "));
502
503    if let Some(description) = result.voice.metadata.get("description") {
504        if description.len() <= 100 {
505            println!("   Description: {}", description);
506        } else {
507            println!("   Description: {}...", &description[..97]);
508        }
509    }
510}
511
512/// Print voice statistics
513fn print_voice_statistics(stats: &VoiceStatistics) {
514    println!("Voice Database Statistics");
515    println!("========================");
516    println!("Total voices: {}", stats.total_voices);
517    println!();
518
519    println!("By Language:");
520    let mut lang_vec: Vec<_> = stats.languages.iter().collect();
521    lang_vec.sort_by_key(|(_, count)| **count);
522    lang_vec.reverse();
523    for (lang, count) in lang_vec {
524        println!("  {}: {}", lang, count);
525    }
526    println!();
527
528    println!("By Gender:");
529    let mut gender_vec: Vec<_> = stats.genders.iter().collect();
530    gender_vec.sort_by_key(|(_, count)| **count);
531    gender_vec.reverse();
532    for (gender, count) in gender_vec {
533        println!("  {}: {}", gender, count);
534    }
535    println!();
536
537    println!("By Quality:");
538    let mut quality_vec: Vec<_> = stats.qualities.iter().collect();
539    quality_vec.sort_by_key(|(_, count)| **count);
540    quality_vec.reverse();
541    for (quality, count) in quality_vec {
542        println!("  {}: {}", quality, count);
543    }
544    println!();
545
546    println!(
547        "Emotion Support: {} voices ({:.1}%)",
548        stats.emotion_support_count,
549        (stats.emotion_support_count as f32 / stats.total_voices as f32) * 100.0
550    );
551}