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}