animalese/
lib.rs

1//! Animalese sound generator
2//!
3//! Recreates the Animal Crossing "animalese" speech effect by playing phonetic
4//! sound sprites with pitch variation and intonation.
5//!
6//! ## Features
7//!
8//! - **8 voice types**: Female (f1-f4) and male (m1-m4) voices
9//! - **Pitch control**: Shift pitch and add random variation for natural sound
10//! - **Intonation**: Apply pitch glides for questions, statements, and excitement
11//! - **Sound effects**: Built-in SFX for keyboard interactions
12//! - **Bundled assets**: Audio files included in the crate
13//!
14//! ## Quick Start
15//!
16//! ```no_run
17//! use animalese::Animalese;
18//!
19//! let engine = Animalese::new()?;
20//! engine.speak("hello world")?;
21//!
22//! // Questions with rising intonation
23//! engine.speak_question("What's that?")?;
24//!
25//! // Excited speech
26//! engine.speak_excited("Amazing!")?;
27//! # Ok::<(), Box<dyn std::error::Error>>(())
28//! ```
29
30use kira::{
31    manager::{AudioManager, AudioManagerSettings, backend::DefaultBackend},
32    sound::static_sound::{StaticSoundData, StaticSoundHandle},
33    tween::Tween,
34    Volume,
35};
36use std::path::{Path, PathBuf};
37use std::sync::{Arc, Mutex};
38use std::time::Duration;
39use rand::Rng;
40
41/// Returns the path to bundled voice assets
42///
43/// Most users don't need this - just use `Animalese::new()`.
44/// Only useful if you need to know where the bundled assets are located.
45pub fn bundled_assets_path() -> PathBuf {
46    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
47        .join("assets")
48        .join("audio")
49        .join("voice")
50}
51
52/// Voice types available
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum VoiceType {
55    F1, F2, F3, F4,
56    M1, M2, M3, M4,
57}
58
59impl VoiceType {
60    fn filename(&self) -> &'static str {
61        match self {
62            VoiceType::F1 => "f1.ogg",
63            VoiceType::F2 => "f2.ogg",
64            VoiceType::F3 => "f3.ogg",
65            VoiceType::F4 => "f4.ogg",
66            VoiceType::M1 => "m1.ogg",
67            VoiceType::M2 => "m2.ogg",
68            VoiceType::M3 => "m3.ogg",
69            VoiceType::M4 => "m4.ogg",
70        }
71    }
72}
73
74/// Voice profile configuration
75#[derive(Debug, Clone)]
76pub struct VoiceProfile {
77    pub voice_type: VoiceType,
78    pub pitch_shift: f32,      // Fixed pitch shift in semitones
79    pub pitch_variation: f32,  // Random variation range in semitones
80    pub volume: f32,           // Volume multiplier (0.0 to 1.0)
81    pub intonation: f32,       // Pitch glide over sentence: -1.0 (falling) to 1.0 (rising)
82}
83
84impl Default for VoiceProfile {
85    fn default() -> Self {
86        Self {
87            voice_type: VoiceType::F1,
88            pitch_shift: 0.0,
89            pitch_variation: 0.8,
90            volume: 0.65,
91            intonation: 0.0,
92        }
93    }
94}
95
96/// Maps letters to their sprite positions in the audio file
97/// Each letter gets 200ms starting at letter_index * 200ms
98fn letter_to_sprite_time(c: char) -> Option<Duration> {
99    let c = c.to_ascii_lowercase();
100    if !c.is_ascii_lowercase() {
101        return None;
102    }
103
104    let index = (c as u32 - 'a' as u32) as u64;
105    Some(Duration::from_millis(index * 200))
106}
107
108/// Special sprite times for non-letter sounds
109fn special_to_sprite_time(name: &str) -> Option<Duration> {
110    match name {
111        "ok" => Some(Duration::from_millis(5200)),
112        "gwah" => Some(Duration::from_millis(5800)),
113        "deska" => Some(Duration::from_millis(6400)),
114        _ => None,
115    }
116}
117
118/// SFX sprite times (600ms each)
119fn sfx_to_sprite_time(name: &str) -> Option<Duration> {
120    let index = match name {
121        "backspace" => 0,
122        "enter" => 1,
123        "tab" => 2,
124        "question" => 3,
125        "exclamation" => 4,
126        "at" => 5,
127        "pound" => 6,
128        "dollar" => 7,
129        "caret" => 8,
130        "ampersand" => 9,
131        "asterisk" => 10,
132        "parenthesis_open" => 11,
133        "parenthesis_closed" => 12,
134        "bracket_open" => 13,
135        "bracket_closed" => 14,
136        "brace_open" => 15,
137        "brace_closed" => 16,
138        "tilde" => 17,
139        "default" => 18,
140        "arrow_left" => 19,
141        "arrow_up" => 20,
142        "arrow_right" => 21,
143        "arrow_down" => 22,
144        "slash_forward" => 23,
145        "slash_back" => 24,
146        "percent" => 25,
147        _ => return None,
148    };
149    Some(Duration::from_millis(index * 600))
150}
151
152/// Calculate playback rate from pitch shift in semitones
153/// rate = 2^(semitones / 12)
154fn semitones_to_rate(semitones: f32) -> f32 {
155    2.0_f32.powf(semitones / 12.0)
156}
157
158/// Animalese sound engine with kira-based playback
159pub struct Animalese {
160    manager: Arc<Mutex<AudioManager>>,
161    voice_path: String,
162    sfx_path: String,
163    profile: Arc<Mutex<VoiceProfile>>,
164    active_sounds: Arc<Mutex<Vec<StaticSoundHandle>>>,
165}
166
167impl Animalese {
168    /// Create a new Animalese engine with bundled assets
169    ///
170    /// # Example
171    /// ```no_run
172    /// use animalese::Animalese;
173    ///
174    /// let engine = Animalese::new().unwrap();
175    /// engine.speak("hello world").unwrap();
176    /// ```
177    pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
178        Self::with_custom_assets(bundled_assets_path().to_string_lossy().to_string())
179    }
180
181    /// Create an Animalese engine with custom audio assets
182    ///
183    /// Only use this if you have custom audio files that match the expected
184    /// format (sprite sheets with 200ms letter sounds, etc).
185    ///
186    /// # Arguments
187    /// * `assets_path` - Path to your custom assets/audio/voice directory
188    ///
189    /// # Example
190    /// ```no_run
191    /// use animalese::Animalese;
192    ///
193    /// let engine = Animalese::with_custom_assets("./my_assets/voice").unwrap();
194    /// ```
195    pub fn with_custom_assets(assets_path: impl Into<String>) -> Result<Self, Box<dyn std::error::Error>> {
196        let voice_path = assets_path.into();
197
198        // SFX file is in parent directory of voice
199        let sfx_path = Path::new(&voice_path)
200            .parent()
201            .ok_or("Invalid assets path")?
202            .join("sfx.ogg")
203            .to_string_lossy()
204            .to_string();
205
206        // Initialize kira audio manager
207        let manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())?;
208
209        let profile = Arc::new(Mutex::new(VoiceProfile::default()));
210        let active_sounds = Arc::new(Mutex::new(Vec::new()));
211
212        Ok(Self {
213            manager: Arc::new(Mutex::new(manager)),
214            voice_path,
215            sfx_path,
216            profile,
217            active_sounds,
218        })
219    }
220
221    /// Set the voice profile
222    pub fn set_profile(&mut self, new_profile: VoiceProfile) {
223        if let Ok(mut profile) = self.profile.lock() {
224            *profile = new_profile;
225        }
226    }
227
228    /// Get a copy of the current voice profile
229    pub fn profile(&self) -> VoiceProfile {
230        self.profile.lock().unwrap().clone()
231    }
232
233    /// Play a letter sound with the current voice profile
234    pub fn play_letter(&self, c: char) -> Result<(), Box<dyn std::error::Error>> {
235        self.play_letter_with_duration(c, None)
236    }
237
238    /// Play a letter sound with optional max duration (for fast typing)
239    pub fn play_letter_with_duration(&self, c: char, max_duration: Option<Duration>) -> Result<(), Box<dyn std::error::Error>> {
240        self.play_letter_with_options(c, max_duration, 0.0)
241    }
242
243    /// Play a letter sound with optional duration and intonation adjustment
244    fn play_letter_with_options(&self, c: char, max_duration: Option<Duration>, intonation_shift: f32) -> Result<(), Box<dyn std::error::Error>> {
245        let sprite_time = letter_to_sprite_time(c)
246            .ok_or("Not a valid letter")?;
247
248        self.play_sprite(&self.voice_path, sprite_time, Duration::from_millis(200), true, max_duration, intonation_shift)
249    }
250
251    /// Play a special sound (ok, gwah, deska)
252    pub fn play_special(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
253        let sprite_time = special_to_sprite_time(name)
254            .ok_or("Unknown special sound")?;
255
256        self.play_sprite(&self.voice_path, sprite_time, Duration::from_millis(600), true, None, 0.0)
257    }
258
259    /// Play a sound effect (enter, backspace, etc)
260    pub fn play_sfx(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
261        let sprite_time = sfx_to_sprite_time(name)
262            .ok_or("Unknown SFX sound")?;
263
264        self.play_sprite(&self.sfx_path, sprite_time, Duration::from_millis(600), false, None, 0.0)
265    }
266
267    /// Play text as animalese speech with intonation
268    pub fn speak(&self, text: &str) -> Result<(), Box<dyn std::error::Error>> {
269        let profile = self.profile.lock().unwrap();
270        let base_intonation = profile.intonation;
271        drop(profile);
272
273        // Check if text ends with question mark for automatic rising intonation
274        let has_question = text.trim_end().ends_with('?');
275        let intonation = if has_question && base_intonation == 0.0 {
276            0.5 // Apply gentle rising intonation for questions
277        } else {
278            base_intonation
279        };
280
281        // Count letters for position calculation
282        let letters: Vec<char> = text.chars().filter(|c| c.is_ascii_alphabetic()).collect();
283        let total_letters = letters.len() as f32;
284
285        if total_letters == 0.0 {
286            return Ok(());
287        }
288
289        let mut letter_index = 0.0;
290        for c in text.chars() {
291            if c.is_ascii_alphabetic() {
292                // Calculate position (0.0 to 1.0) in the sentence
293                let position = letter_index / total_letters;
294
295                // Apply intonation curve
296                // Positive intonation = rising (pitch increases)
297                // Negative intonation = falling (pitch decreases)
298                let intonation_shift = intonation * position * 3.0; // Scale to ~3 semitones max
299
300                self.play_letter_with_options(c, None, intonation_shift)?;
301                letter_index += 1.0;
302
303                // Small delay between letters to simulate speech cadence
304                std::thread::sleep(Duration::from_millis(50));
305            }
306        }
307        Ok(())
308    }
309
310    /// Speak text with rising intonation (for questions)
311    ///
312    /// Automatically applies a rising pitch contour, perfect for questions
313    /// or uncertain statements.
314    ///
315    /// # Example
316    /// ```no_run
317    /// use animalese::Animalese;
318    ///
319    /// let engine = Animalese::new().unwrap();
320    /// engine.speak_question("What's that").unwrap();
321    /// ```
322    pub fn speak_question(&self, text: &str) -> Result<(), Box<dyn std::error::Error>> {
323        // Temporarily set intonation to rising
324        let original_intonation = {
325            let mut profile = self.profile.lock().unwrap();
326            let original = profile.intonation;
327            profile.intonation = 0.6; // Moderate rising intonation
328            original
329        };
330
331        let result = self.speak(text);
332
333        // Restore original intonation
334        if let Ok(mut profile) = self.profile.lock() {
335            profile.intonation = original_intonation;
336        }
337
338        result
339    }
340
341    /// Speak text with excitement (higher pitch, rising intonation)
342    ///
343    /// Applies higher pitch and rising intonation for excited or enthusiastic
344    /// speech. Great for exclamations!
345    ///
346    /// # Example
347    /// ```no_run
348    /// use animalese::Animalese;
349    ///
350    /// let engine = Animalese::new().unwrap();
351    /// engine.speak_excited("Amazing!").unwrap();
352    /// ```
353    pub fn speak_excited(&self, text: &str) -> Result<(), Box<dyn std::error::Error>> {
354        // Temporarily boost pitch and add rising intonation
355        let (original_pitch, original_intonation) = {
356            let mut profile = self.profile.lock().unwrap();
357            let orig_pitch = profile.pitch_shift;
358            let orig_intonation = profile.intonation;
359            profile.pitch_shift += 2.0; // Raise pitch by 2 semitones
360            profile.intonation = 0.4; // Gentle rising intonation
361            (orig_pitch, orig_intonation)
362        };
363
364        let result = self.speak(text);
365
366        // Restore original settings
367        if let Ok(mut profile) = self.profile.lock() {
368            profile.pitch_shift = original_pitch;
369            profile.intonation = original_intonation;
370        }
371
372        result
373    }
374
375    /// Speak text with falling intonation (for statements)
376    ///
377    /// Applies a gentle falling pitch contour, typical of declarative
378    /// statements and confident assertions.
379    ///
380    /// # Example
381    /// ```no_run
382    /// use animalese::Animalese;
383    ///
384    /// let engine = Animalese::new().unwrap();
385    /// engine.speak_statement("I see").unwrap();
386    /// ```
387    pub fn speak_statement(&self, text: &str) -> Result<(), Box<dyn std::error::Error>> {
388        // Temporarily set intonation to falling
389        let original_intonation = {
390            let mut profile = self.profile.lock().unwrap();
391            let original = profile.intonation;
392            profile.intonation = -0.3; // Gentle falling intonation
393            original
394        };
395
396        let result = self.speak(text);
397
398        // Restore original intonation
399        if let Ok(mut profile) = self.profile.lock() {
400            profile.intonation = original_intonation;
401        }
402
403        result
404    }
405
406    /// Internal method to play a sprite with kira
407    fn play_sprite(&self, audio_path: &str, start: Duration, duration: Duration, apply_pitch: bool, max_duration: Option<Duration>, intonation_shift: f32) -> Result<(), Box<dyn std::error::Error>> {
408        // Determine the full file path
409        let file_path = if audio_path.ends_with(".ogg") {
410            // It's already a full path to sfx.ogg
411            audio_path.to_string()
412        } else {
413            // It's the voice directory, append the voice filename
414            let profile = self.profile.lock().unwrap();
415            let filename = profile.voice_type.filename();
416            Path::new(audio_path).join(filename)
417                .to_string_lossy()
418                .to_string()
419        };
420
421        // Calculate parameters
422        let actual_duration = max_duration.unwrap_or(duration);
423
424        // Load sound data and slice to extract only the sprite region
425        let start_time = start.as_secs_f64();
426        let end_time = start_time + actual_duration.as_secs_f64();
427        let mut sound_data = StaticSoundData::from_file(&file_path)?
428            .slice(start_time..end_time);
429
430        if apply_pitch {
431            let profile = self.profile.lock().unwrap();
432            let mut rng = rand::thread_rng();
433            let random_variation = rng.gen_range(-1.0..=1.0) * profile.pitch_variation;
434            let final_pitch = profile.pitch_shift + random_variation + intonation_shift;
435            let playback_rate = semitones_to_rate(final_pitch);
436            let volume = profile.volume;
437
438            // Configure sound with pitch and volume
439            sound_data = sound_data
440                .playback_rate(playback_rate as f64)
441                .volume(Volume::Amplitude(volume as f64))
442                .fade_in_tween(Tween {
443                    duration: std::time::Duration::from_millis(5),
444                    ..Default::default()
445                });
446        } else {
447            let profile = self.profile.lock().unwrap();
448            let volume = profile.volume;
449
450            sound_data = sound_data
451                .volume(Volume::Amplitude(volume as f64))
452                .fade_in_tween(Tween {
453                    duration: std::time::Duration::from_millis(5),
454                    ..Default::default()
455                });
456        }
457
458        // Play the sound
459        let mut manager = self.manager.lock().unwrap();
460        let handle = manager.play(sound_data)?;
461
462        // Store handle to keep it alive
463        let mut active = self.active_sounds.lock().unwrap();
464        active.push(handle);
465
466        // Clean up finished sounds
467        active.retain(|h| h.state() != kira::sound::PlaybackState::Stopped);
468
469        Ok(())
470    }
471
472    /// Stop all currently playing sounds
473    pub fn stop(&self) {
474        let mut active = self.active_sounds.lock().unwrap();
475        for handle in active.iter_mut() {
476            let _ = handle.stop(Tween::default());
477        }
478        active.clear();
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use std::time::Duration;
486
487    #[test]
488    fn test_letter_to_sprite_time() {
489        assert_eq!(letter_to_sprite_time('a'), Some(Duration::from_millis(0)));
490        assert_eq!(letter_to_sprite_time('b'), Some(Duration::from_millis(200)));
491        assert_eq!(letter_to_sprite_time('z'), Some(Duration::from_millis(5000)));
492        assert_eq!(letter_to_sprite_time('A'), Some(Duration::from_millis(0)));
493        assert_eq!(letter_to_sprite_time('1'), None);
494    }
495
496    #[test]
497    fn test_semitones_to_rate() {
498        assert!((semitones_to_rate(0.0) - 1.0).abs() < 0.001);
499        assert!((semitones_to_rate(12.0) - 2.0).abs() < 0.001);
500        assert!((semitones_to_rate(-12.0) - 0.5).abs() < 0.001);
501    }
502
503    #[test]
504    fn test_special_sounds() {
505        assert_eq!(special_to_sprite_time("ok"), Some(Duration::from_millis(5200)));
506        assert_eq!(special_to_sprite_time("gwah"), Some(Duration::from_millis(5800)));
507        assert_eq!(special_to_sprite_time("deska"), Some(Duration::from_millis(6400)));
508        assert_eq!(special_to_sprite_time("unknown"), None);
509    }
510
511    #[test]
512    fn test_voice_profile_default() {
513        let profile = VoiceProfile::default();
514        assert_eq!(profile.voice_type, VoiceType::F1);
515        assert_eq!(profile.pitch_shift, 0.0);
516        assert_eq!(profile.pitch_variation, 0.8);
517        assert_eq!(profile.volume, 0.65);
518        assert_eq!(profile.intonation, 0.0);
519    }
520
521    #[test]
522    fn test_intonation_values() {
523        let mut profile = VoiceProfile::default();
524
525        // Test setting various intonation values
526        profile.intonation = 0.5;
527        assert_eq!(profile.intonation, 0.5);
528
529        profile.intonation = -0.5;
530        assert_eq!(profile.intonation, -0.5);
531
532        profile.intonation = 1.0;
533        assert_eq!(profile.intonation, 1.0);
534
535        profile.intonation = -1.0;
536        assert_eq!(profile.intonation, -1.0);
537    }
538}