Skip to main content

prani/
emotion.rs

1//! Creature emotion state machine — valence/arousal model.
2//!
3//! Maps a 2D emotional state (valence × arousal) to vocalization selection,
4//! call intent, and continuous synthesis parameters. Provides smooth
5//! transitions between emotional states for natural-sounding behavior.
6//!
7//! ```text
8//! High Arousal
9//!   Alarm/Screech ←──── Arousal ────→ Excited/Trill
10//!        (−V, +A)                       (+V, +A)
11//!            │                              │
12//!            │          Neutral             │
13//!            │         (Idle/Growl)          │
14//!            │                              │
15//!   Sullen/Rumble ←──── Valence ───→ Content/Purr
16//!        (−V, −A)                       (+V, −A)
17//! Low Arousal
18//! ```
19
20use serde::{Deserialize, Serialize};
21
22use crate::vocalization::{CallIntent, Vocalization};
23
24/// A creature's emotional state in valence/arousal space.
25///
26/// This drives vocalization selection, call intent, and continuous
27/// parameter modulation (vocal effort, breathiness, pitch scale).
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[must_use]
30pub struct EmotionState {
31    /// Valence: −1.0 (negative/aversive) to +1.0 (positive/appetitive).
32    valence: f32,
33    /// Arousal: 0.0 (calm/sleepy) to 1.0 (excited/alert).
34    arousal: f32,
35    /// Smoothing factor for state transitions (0.0 = instant, 1.0 = very slow).
36    /// Controls how quickly the creature's emotional state changes.
37    smoothing: f32,
38}
39
40/// The output of the emotion state machine — parameters to drive synthesis.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[must_use]
43pub struct EmotionOutput {
44    /// Suggested vocalization for this emotional state.
45    pub vocalization: Vocalization,
46    /// Behavioral intent.
47    pub intent: CallIntent,
48    /// Vocal effort (0.0–1.0), derived from arousal.
49    pub vocal_effort: f32,
50    /// Pitch scale multiplier, derived from valence.
51    pub pitch_scale: f32,
52    /// Breathiness modifier (additive), derived from arousal extremes.
53    pub breathiness_delta: f32,
54}
55
56impl EmotionState {
57    /// Creates a new emotion state at the neutral point (idle, calm).
58    pub fn new() -> Self {
59        Self {
60            valence: 0.0,
61            arousal: 0.2,
62            smoothing: 0.1,
63        }
64    }
65
66    /// Creates an emotion state with specific initial values.
67    pub fn with_values(valence: f32, arousal: f32) -> Self {
68        Self {
69            valence: valence.clamp(-1.0, 1.0),
70            arousal: arousal.clamp(0.0, 1.0),
71            smoothing: 0.1,
72        }
73    }
74
75    /// Sets the smoothing factor (0.0 = instant transitions, 0.95 = very sluggish).
76    pub fn with_smoothing(mut self, smoothing: f32) -> Self {
77        self.smoothing = smoothing.clamp(0.0, 0.95);
78        self
79    }
80
81    /// Returns the current valence.
82    #[must_use]
83    pub fn valence(&self) -> f32 {
84        self.valence
85    }
86
87    /// Returns the current arousal.
88    #[must_use]
89    pub fn arousal(&self) -> f32 {
90        self.arousal
91    }
92
93    /// Updates the emotional state toward a target, applying smoothing.
94    ///
95    /// Call this once per frame or tick. The state exponentially decays
96    /// toward `(target_valence, target_arousal)` at a rate controlled
97    /// by `smoothing`.
98    pub fn update(&mut self, target_valence: f32, target_arousal: f32) {
99        let tv = target_valence.clamp(-1.0, 1.0);
100        let ta = target_arousal.clamp(0.0, 1.0);
101        let s = self.smoothing;
102        self.valence = self.valence * s + tv * (1.0 - s);
103        self.arousal = self.arousal * s + ta * (1.0 - s);
104    }
105
106    /// Sets the emotional state directly (no smoothing).
107    pub fn set(&mut self, valence: f32, arousal: f32) {
108        self.valence = valence.clamp(-1.0, 1.0);
109        self.arousal = arousal.clamp(0.0, 1.0);
110    }
111
112    /// Evaluates the current emotional state into synthesis parameters.
113    ///
114    /// This is the core mapping from the 2D emotion space to concrete
115    /// vocalization choices and parameter values.
116    #[must_use = "returns synthesis parameters derived from the emotional state"]
117    pub fn evaluate(&self) -> EmotionOutput {
118        let vocalization = self.select_vocalization();
119        let intent = self.select_intent();
120
121        // Vocal effort driven primarily by arousal
122        // Low arousal → whisper (0.2), high arousal → shout (0.9)
123        let vocal_effort = 0.2 + self.arousal * 0.7;
124
125        // Pitch scale from valence: positive = slightly higher, negative = lower
126        let pitch_scale = 1.0 + self.valence * 0.15;
127
128        // Breathiness increases at arousal extremes (panting when very excited,
129        // breathy when very calm/sleepy)
130        let breathiness_delta = if self.arousal > 0.8 {
131            (self.arousal - 0.8) * 0.75 // up to +0.15 at max arousal
132        } else if self.arousal < 0.2 {
133            (0.2 - self.arousal) * 0.5 // up to +0.1 at min arousal
134        } else {
135            0.0
136        };
137
138        EmotionOutput {
139            vocalization,
140            intent,
141            vocal_effort,
142            pitch_scale,
143            breathiness_delta,
144        }
145    }
146
147    /// Selects the most appropriate vocalization for the current state.
148    fn select_vocalization(&self) -> Vocalization {
149        match (self.valence_zone(), self.arousal_zone()) {
150            // High arousal, negative valence → alarm/threat sounds
151            (ValenceZone::Negative, ArousalZone::High) => {
152                if self.valence < -0.5 {
153                    Vocalization::Screech
154                } else {
155                    Vocalization::Bark
156                }
157            }
158            // High arousal, positive valence → excited sounds
159            (ValenceZone::Positive, ArousalZone::High) => {
160                if self.valence > 0.5 {
161                    Vocalization::Trill
162                } else {
163                    Vocalization::Chirp
164                }
165            }
166            // High arousal, neutral valence → alert
167            (ValenceZone::Neutral, ArousalZone::High) => Vocalization::Bark,
168
169            // Low arousal, negative valence → sullen
170            (ValenceZone::Negative, ArousalZone::Low) => Vocalization::Growl,
171            // Low arousal, positive valence → content
172            (ValenceZone::Positive, ArousalZone::Low) => Vocalization::Purr,
173            // Low arousal, neutral → idle rumble
174            (ValenceZone::Neutral, ArousalZone::Low) => Vocalization::Rumble,
175
176            // Mid arousal, negative → threat
177            (ValenceZone::Negative, ArousalZone::Mid) => Vocalization::Growl,
178            // Mid arousal, positive → social
179            (ValenceZone::Positive, ArousalZone::Mid) => Vocalization::Howl,
180            // Mid arousal, neutral → idle
181            (ValenceZone::Neutral, ArousalZone::Mid) => Vocalization::Whine,
182        }
183    }
184
185    /// Selects the call intent from the current state.
186    fn select_intent(&self) -> CallIntent {
187        match (self.valence_zone(), self.arousal_zone()) {
188            (ValenceZone::Negative, ArousalZone::High) => {
189                if self.valence < -0.7 {
190                    CallIntent::Distress
191                } else {
192                    CallIntent::Alarm
193                }
194            }
195            (ValenceZone::Positive, ArousalZone::High) => CallIntent::Mating,
196            (ValenceZone::Neutral, ArousalZone::High) => CallIntent::Alarm,
197            (ValenceZone::Negative, ArousalZone::Low) => CallIntent::Threat,
198            (ValenceZone::Positive, ArousalZone::Low) => CallIntent::Idle,
199            (ValenceZone::Neutral, ArousalZone::Low) => CallIntent::Idle,
200            (ValenceZone::Negative, ArousalZone::Mid) => CallIntent::Threat,
201            (ValenceZone::Positive, ArousalZone::Mid) => CallIntent::Social,
202            (ValenceZone::Neutral, ArousalZone::Mid) => CallIntent::Social,
203        }
204    }
205
206    fn valence_zone(&self) -> ValenceZone {
207        if self.valence < -0.2 {
208            ValenceZone::Negative
209        } else if self.valence > 0.2 {
210            ValenceZone::Positive
211        } else {
212            ValenceZone::Neutral
213        }
214    }
215
216    fn arousal_zone(&self) -> ArousalZone {
217        if self.arousal < 0.33 {
218            ArousalZone::Low
219        } else if self.arousal > 0.66 {
220            ArousalZone::High
221        } else {
222            ArousalZone::Mid
223        }
224    }
225}
226
227impl Default for EmotionState {
228    fn default() -> Self {
229        Self::new()
230    }
231}
232
233#[derive(Debug, Clone, Copy)]
234enum ValenceZone {
235    Negative,
236    Neutral,
237    Positive,
238}
239
240#[derive(Debug, Clone, Copy)]
241enum ArousalZone {
242    Low,
243    Mid,
244    High,
245}