1use anyhow::Result;
30use async_trait::async_trait;
31use std::{
32 collections::HashMap,
33 sync::{Arc, Mutex},
34 time::{SystemTime, UNIX_EPOCH},
35};
36use tokio::sync::mpsc;
37
38use wactorz_core::{Actor, ActorConfig, ActorMetrics, ActorState, EventPublisher, Message};
39
40const NOTE_NAMES: [&str; 12] = [
43 "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
44];
45
46const INTERVAL_NAMES: [&str; 13] = [
47 "Unison (P1)",
48 "Minor 2nd (m2)",
49 "Major 2nd (M2)",
50 "Minor 3rd (m3)",
51 "Major 3rd (M3)",
52 "Perfect 4th (P4)",
53 "Tritone (A4/d5)",
54 "Perfect 5th (P5)",
55 "Minor 6th (m6)",
56 "Major 6th (M6)",
57 "Minor 7th (m7)",
58 "Major 7th (M7)",
59 "Octave (P8)",
60];
61
62fn note_index(s: &str) -> Option<usize> {
63 match s
64 .to_uppercase()
65 .replace("♭", "B")
66 .replace("♯", "#")
67 .as_str()
68 {
69 "C" | "B#" => Some(0),
70 "C#" | "DB" => Some(1),
71 "D" => Some(2),
72 "D#" | "EB" => Some(3),
73 "E" | "FB" => Some(4),
74 "F" | "E#" => Some(5),
75 "F#" | "GB" => Some(6),
76 "G" => Some(7),
77 "G#" | "AB" => Some(8),
78 "A" => Some(9),
79 "A#" | "BB" => Some(10),
80 "B" | "CB" => Some(11),
81 _ => None,
82 }
83}
84
85fn parse_root(token: &str) -> Option<(usize, &str)> {
88 if token.len() >= 2 {
90 let two = &token[..2];
91 if let Some(idx) = note_index(two) {
92 return Some((idx, &token[2..]));
93 }
94 }
95 if !token.is_empty()
97 && let Some(idx) = note_index(&token[..1])
98 {
99 return Some((idx, &token[1..]));
100 }
101 None
102}
103
104fn build_chord(root: usize, quality: &str) -> Option<(&'static str, Vec<usize>)> {
105 let q = quality.to_lowercase();
106 let (name, intervals): (&'static str, &[usize]) = match q.trim_matches(|c: char| c == ' ') {
107 "" | "maj" | "major" => ("major", &[0, 4, 7]),
108 "m" | "min" | "minor" | "-" => ("minor", &[0, 3, 7]),
109 "dim" | "°" | "diminished" => ("diminished", &[0, 3, 6]),
110 "aug" | "+" | "augmented" => ("augmented", &[0, 4, 8]),
111 "7" | "dom7" => ("dominant 7th", &[0, 4, 7, 10]),
112 "maj7" | "m7" if q == "maj7" => ("major 7th", &[0, 4, 7, 11]),
113 "m7" | "min7" => ("minor 7th", &[0, 3, 7, 10]),
114 "dim7" | "°7" => ("diminished 7th", &[0, 3, 6, 9]),
115 "sus2" => ("sus2", &[0, 2, 7]),
116 "sus4" => ("sus4", &[0, 5, 7]),
117 "add9" => ("add9", &[0, 4, 7, 14]),
118 "6" => ("major 6th", &[0, 4, 7, 9]),
119 "m6" => ("minor 6th", &[0, 3, 7, 9]),
120 _ => return None,
121 };
122 let notes: Vec<usize> = intervals.iter().map(|&i| (root + i) % 12).collect();
123 Some((name, notes))
124}
125
126fn build_scale(root: usize, mode: &str) -> Option<(&'static str, Vec<usize>)> {
127 let m = mode.to_lowercase();
128 let (name, intervals): (&'static str, &[usize]) = match m.trim() {
129 "major" | "ionian" | "maj" => ("Major (Ionian)", &[0, 2, 4, 5, 7, 9, 11]),
130 "minor" | "natural minor" | "aeolian" | "min" => {
131 ("Natural Minor (Aeolian)", &[0, 2, 3, 5, 7, 8, 10])
132 }
133 "dorian" => ("Dorian", &[0, 2, 3, 5, 7, 9, 10]),
134 "phrygian" => ("Phrygian", &[0, 1, 3, 5, 7, 8, 10]),
135 "lydian" => ("Lydian", &[0, 2, 4, 6, 7, 9, 11]),
136 "mixolydian" | "mixo" => ("Mixolydian", &[0, 2, 4, 5, 7, 9, 10]),
137 "locrian" => ("Locrian", &[0, 1, 3, 5, 6, 8, 10]),
138 "harmonic minor" | "harm" => ("Harmonic Minor", &[0, 2, 3, 5, 7, 8, 11]),
139 "melodic minor" | "mel" => ("Melodic Minor (asc)", &[0, 2, 3, 5, 7, 9, 11]),
140 "pentatonic major" | "pent major" | "pent maj" => ("Pentatonic Major", &[0, 2, 4, 7, 9]),
141 "pentatonic minor" | "pent minor" | "pent min" => ("Pentatonic Minor", &[0, 3, 5, 7, 10]),
142 "blues" => ("Blues", &[0, 3, 5, 6, 7, 10]),
143 _ => return None,
144 };
145 let notes: Vec<usize> = intervals.iter().map(|&i| (root + i) % 12).collect();
146 Some((name, notes))
147}
148
149fn bpm_tempo_name(bpm: u32) -> &'static str {
150 match bpm {
151 0..=59 => "Largo (very slow)",
152 60..=65 => "Larghetto",
153 66..=75 => "Adagio (slow)",
154 76..=107 => "Andante (walking pace)",
155 108..=119 => "Moderato",
156 120..=155 => "Allegro (fast)",
157 156..=175 => "Vivace (lively)",
158 176..=199 => "Presto (very fast)",
159 _ => "Prestissimo (extremely fast)",
160 }
161}
162
163#[derive(Clone)]
166struct MusicEntry {
167 title: String,
168 entry_type: String, artist: Option<String>,
170 rating: Option<f64>,
171 genre: Option<String>,
172 #[expect(dead_code)]
173 ts_ms: u64,
174}
175
176pub struct WisAgent {
179 config: ActorConfig,
180 state: ActorState,
181 metrics: Arc<ActorMetrics>,
182 mailbox_tx: mpsc::Sender<Message>,
183 mailbox_rx: Option<mpsc::Receiver<Message>>,
184 publisher: Option<EventPublisher>,
185 log: Arc<Mutex<Vec<MusicEntry>>>,
186}
187
188impl WisAgent {
189 pub fn new(config: ActorConfig) -> Self {
190 let (tx, rx) = mpsc::channel(config.mailbox_capacity);
191 Self {
192 config,
193 state: ActorState::Initializing,
194 metrics: Arc::new(ActorMetrics::new()),
195 mailbox_tx: tx,
196 mailbox_rx: Some(rx),
197 publisher: None,
198 log: Arc::new(Mutex::new(Vec::new())),
199 }
200 }
201
202 pub fn with_publisher(mut self, p: EventPublisher) -> Self {
203 self.publisher = Some(p);
204 self
205 }
206
207 fn now_ms() -> u64 {
208 SystemTime::now()
209 .duration_since(UNIX_EPOCH)
210 .unwrap_or_default()
211 .as_millis() as u64
212 }
213
214 fn reply(&self, content: &str) {
215 if let Some(pub_) = &self.publisher {
216 pub_.publish(
217 wactorz_mqtt::topics::chat(&self.config.id),
218 &serde_json::json!({
219 "from": self.config.name,
220 "to": "user",
221 "content": content,
222 "timestampMs": Self::now_ms(),
223 }),
224 );
225 }
226 }
227
228 fn cmd_add(&self, parts: &[&str]) -> String {
231 if parts.len() < 2 {
233 return "Usage: `add <song|album|artist> \"<title>\" [artist] [rating] [genre]`\n\n\
234 Examples:\n\
235 • `add song \"Clair de Lune\" Debussy 10 classical`\n\
236 • `add album \"Kind of Blue\" \"Miles Davis\" 9.5 jazz`\n\
237 • `add artist Bach 10 baroque`"
238 .to_string();
239 }
240
241 let entry_type = parts[0].to_lowercase();
242 if !["song", "album", "artist"].contains(&entry_type.as_str()) {
243 return format!("Unknown type `{entry_type}`. Use: `song`, `album`, or `artist`.");
244 }
245
246 let rest = parts[1..].join(" ");
248 let (title, remainder) = if let Some(stripped) = rest.strip_prefix('"') {
249 if let Some(end) = stripped.find('"') {
250 (
251 stripped[..end].to_string(),
252 stripped[end + 1..].trim().to_string(),
253 )
254 } else {
255 (stripped.trim_matches('"').to_string(), String::new())
256 }
257 } else {
258 let mut it = parts[1..].iter();
259 let t = it.next().unwrap_or(&"").to_string();
260 let r: Vec<&str> = it.copied().collect();
261 (t, r.join(" "))
262 };
263
264 let rem: Vec<&str> = remainder.split_whitespace().collect();
265
266 let (artist, raw_rating, raw_genre): (Option<String>, Option<&str>, Option<&str>) =
269 match entry_type.as_str() {
270 "artist" => {
271 let rat = rem.first().copied();
272 let gn = rem.get(1).copied();
273 (None, rat, gn)
274 }
275 _ => {
276 let first = rem.first().copied();
278 let is_rating = first
279 .map(|s: &str| s.parse::<f64>().is_ok())
280 .unwrap_or(false);
281 if is_rating {
282 (None, first, rem.get(1).copied())
283 } else {
284 (
285 first.map(|s| s.to_string()),
286 rem.get(1).copied(),
287 rem.get(2).copied(),
288 )
289 }
290 }
291 };
292
293 let rating: Option<f64> = raw_rating.and_then(|s: &str| {
294 s.trim_end_matches('/')
295 .parse::<f64>()
296 .ok()
297 .filter(|&v| (0.0..=10.0).contains(&v))
298 });
299
300 self.log.lock().unwrap().push(MusicEntry {
301 title: title.clone(),
302 entry_type: entry_type.clone(),
303 artist: artist.clone(),
304 rating,
305 genre: raw_genre.map(|s| s.to_string()),
306 ts_ms: Self::now_ms(),
307 });
308
309 let icon = match entry_type.as_str() {
310 "song" => "🎵",
311 "album" => "💿",
312 _ => "🎤",
313 };
314 let rating_str = rating.map(|r| format!(" ⭐ {r:.1}/10")).unwrap_or_default();
315 let artist_str = artist.map(|a| format!(" — _{a}_")).unwrap_or_default();
316 let genre_str = raw_genre.map(|g| format!(" `{g}`")).unwrap_or_default();
317 format!("{icon} Logged **{title}**{artist_str}{rating_str}{genre_str}")
318 }
319
320 fn cmd_stats(&self, filter: Option<&str>) -> String {
321 let log = self.log.lock().unwrap();
322 if log.is_empty() {
323 return "📭 Nothing logged yet.\n\nTry: `add song \"Clair de Lune\" Debussy 10 classical`".to_string();
324 }
325
326 let entries: Vec<&MusicEntry> = match filter {
327 Some(t) if ["song", "album", "artist"].contains(&t) => {
328 log.iter().filter(|e| e.entry_type == t).collect()
329 }
330 Some(g) => {
331 log.iter()
333 .filter(|e| {
334 e.genre
335 .as_deref()
336 .map(|eg| eg.eq_ignore_ascii_case(g))
337 .unwrap_or(false)
338 })
339 .collect()
340 }
341 None => log.iter().collect(),
342 };
343
344 if entries.is_empty() {
345 return format!("📭 No entries for `{}`.", filter.unwrap_or(""));
346 }
347
348 let mut by_type: HashMap<&str, (usize, f64, u32)> = HashMap::new();
349 for e in &entries {
350 let rec = by_type.entry(e.entry_type.as_str()).or_insert((0, 0.0, 0));
351 rec.0 += 1;
352 if let Some(r) = e.rating {
353 rec.1 += r;
354 rec.2 += 1;
355 }
356 }
357
358 let total = entries.len();
359 let total_rated: u32 = by_type.values().map(|r| r.2).sum();
360 let total_rating: f64 = by_type.values().map(|r| r.1).sum();
361 let avg = if total_rated > 0 {
362 total_rating / total_rated as f64
363 } else {
364 0.0
365 };
366
367 let mut rows = Vec::new();
368 for t in &["song", "album", "artist"] {
369 if let Some(&(count, rs, rc)) = by_type.get(*t) {
370 let avg_t = if rc > 0 {
371 format!("avg ⭐ {:.1}", rs / rc as f64)
372 } else {
373 "unrated".to_string()
374 };
375 let icon = match *t {
376 "song" => "🎵",
377 "album" => "💿",
378 _ => "🎤",
379 };
380 rows.push(format!(
381 " {icon} **{}s**: {count} — {avg_t}",
382 Self::capitalize(t)
383 ));
384 }
385 }
386
387 let header = match filter {
388 Some(f) => format!("**🎧 Music Stats — {}**", Self::capitalize(f)),
389 None => "**🎧 Music Stats — All Time**".to_string(),
390 };
391 let avg_line = if total_rated > 0 {
392 format!("\n\n**Overall avg**: ⭐ {avg:.1}/10 ({total_rated} rated)")
393 } else {
394 String::new()
395 };
396
397 format!(
398 "{header}\n\n{}\n\n**Total**: {total} entries{avg_line}",
399 rows.join("\n")
400 )
401 }
402
403 fn cmd_top(&self, parts: &[&str]) -> String {
404 let n: usize = parts
405 .first()
406 .and_then(|s| s.parse().ok())
407 .unwrap_or(5)
408 .min(20);
409 let type_offset = if parts
410 .first()
411 .and_then(|s| s.parse::<usize>().ok())
412 .is_some()
413 {
414 1
415 } else {
416 0
417 };
418 let filter = parts.get(type_offset).copied();
419
420 let log = self.log.lock().unwrap();
421 let mut rated: Vec<&MusicEntry> = log
422 .iter()
423 .filter(|e| e.rating.is_some())
424 .filter(|e| filter.map(|t| e.entry_type == t).unwrap_or(true))
425 .collect();
426
427 if rated.is_empty() {
428 return "📭 No rated entries yet.\n\nTry: `add album \"Kind of Blue\" \"Miles Davis\" 9.5 jazz`".to_string();
429 }
430
431 rated.sort_by(|a, b| {
432 b.rating
433 .unwrap_or(0.0)
434 .partial_cmp(&a.rating.unwrap_or(0.0))
435 .unwrap_or(std::cmp::Ordering::Equal)
436 });
437
438 let type_label = filter
439 .map(|t| format!(" — {}s", Self::capitalize(t)))
440 .unwrap_or_default();
441 let rows: Vec<String> = rated
442 .iter()
443 .take(n)
444 .enumerate()
445 .map(|(i, e)| {
446 let icon = match e.entry_type.as_str() {
447 "song" => "🎵",
448 "album" => "💿",
449 _ => "🎤",
450 };
451 let artist = e
452 .artist
453 .as_deref()
454 .map(|a| format!(" — _{a}_"))
455 .unwrap_or_default();
456 let genre = e
457 .genre
458 .as_deref()
459 .map(|g| format!(" `{g}`"))
460 .unwrap_or_default();
461 format!(
462 " {}. {icon} **{}**{artist} ⭐ {:.1}{genre}",
463 i + 1,
464 e.title,
465 e.rating.unwrap_or(0.0)
466 )
467 })
468 .collect();
469
470 format!("**🏆 Top {n}{type_label}**\n\n{}", rows.join("\n"))
471 }
472
473 fn cmd_theory(&self, parts: &[&str]) -> String {
476 match parts.first().copied().unwrap_or("") {
477 "chord" => {
478 if parts.len() < 2 {
479 return "Usage: `theory chord <root>[quality]`\n\n\
480 Examples:\n\
481 • `theory chord Am` → minor triad\n\
482 • `theory chord Cmaj7` → major 7th\n\
483 • `theory chord F#dim` → diminished\n\n\
484 Qualities: (blank)=major m=minor dim aug 7 maj7 m7 dim7 sus2 sus4 add9 6 m6".to_string();
485 }
486
487 let chord_str = parts[1..].join("");
489 let (root_idx, quality) = match parse_root(&chord_str) {
490 Some(r) => r,
491 None => return format!("❓ Could not parse chord: `{chord_str}`"),
492 };
493
494 match build_chord(root_idx, quality) {
495 None => format!(
496 "❓ Unknown quality `{quality}`.\n\n\
497 Valid qualities: (blank) m dim aug 7 maj7 m7 dim7 sus2 sus4 add9 6 m6"
498 ),
499 Some((name, notes)) => {
500 let root_name = NOTE_NAMES[root_idx];
501 let note_list: Vec<&str> = notes.iter().map(|&n| NOTE_NAMES[n]).collect();
502 format!(
503 "**🎹 {} {}**\n\nNotes: **{}**\nDegrees: {}",
504 root_name,
505 name,
506 note_list.join(" "),
507 note_list
508 .iter()
509 .enumerate()
510 .map(|(i, n)| format!("{}: {}", i + 1, n))
511 .collect::<Vec<_>>()
512 .join(" · "),
513 )
514 }
515 }
516 }
517
518 "scale" => {
519 if parts.len() < 2 {
520 return "Usage: `theory scale <root> <mode>`\n\n\
521 Examples:\n\
522 • `theory scale C major`\n\
523 • `theory scale A dorian`\n\
524 • `theory scale F# blues`\n\n\
525 Modes: major minor dorian phrygian lydian mixolydian locrian\n\
526 harmonic minor melodic minor pentatonic major pentatonic minor blues".to_string();
527 }
528
529 let (root_idx, suffix) = match parse_root(parts[1]) {
530 Some(r) => r,
531 None => return format!("❓ Could not parse root note: `{}`", parts[1]),
532 };
533
534 let mode_parts: Vec<&str> = {
536 let mut v = vec![suffix.trim()];
537 v.extend_from_slice(&parts[2..]);
538 v.retain(|s| !s.is_empty());
539 v
540 };
541 let mode = mode_parts.join(" ");
542
543 match build_scale(root_idx, &mode) {
544 None => format!(
545 "❓ Unknown mode `{mode}`.\n\nValid modes: major · minor · dorian · phrygian · \
546 lydian · mixolydian · locrian · harmonic minor · melodic minor · \
547 pentatonic major · pentatonic minor · blues"
548 ),
549 Some((name, notes)) => {
550 let root_name = NOTE_NAMES[root_idx];
551 let note_list: Vec<&str> = notes.iter().map(|&n| NOTE_NAMES[n]).collect();
552 format!(
553 "**🎼 {} {}**\n\nNotes ({} tones): **{}**",
554 root_name,
555 name,
556 notes.len(),
557 note_list.join(" "),
558 )
559 }
560 }
561 }
562
563 "bpm" => {
564 if parts.len() < 2 {
565 return "Usage: `theory bpm <tempo>`\n\nExample: `theory bpm 128`".to_string();
566 }
567 let bpm: u32 = match parts[1].parse() {
568 Ok(v) if v > 0 => v,
569 _ => return format!("❓ Invalid BPM: `{}`", parts[1]),
570 };
571 let tempo_name = bpm_tempo_name(bpm);
572 let beat_ms = 60_000.0 / bpm as f64;
573 let half_ms = beat_ms * 2.0;
574 let eighth_ms = beat_ms / 2.0;
575 let sixteenth = beat_ms / 4.0;
576 let bar_4_4 = beat_ms * 4.0;
577 let delay_8th = eighth_ms;
579 let delay_dot8 = eighth_ms * 1.5;
580 let delay_16 = sixteenth;
581
582 format!(
583 "**🥁 BPM: {bpm}** — {tempo_name}\n\n\
584 **Beat grid (4/4)**\n\
585 Whole note : {:.1} ms\n\
586 Half note : {:.1} ms\n\
587 Quarter (♩) : **{:.1} ms**\n\
588 8th note : {:.1} ms\n\
589 16th note : {:.1} ms\n\n\
590 **Delay times**\n\
591 1/8 : {:.1} ms\n\
592 Dot 1/8 : {:.1} ms\n\
593 1/16 : {:.1} ms",
594 bar_4_4 * 2.0,
595 half_ms,
596 beat_ms,
597 eighth_ms,
598 sixteenth,
599 delay_8th,
600 delay_dot8,
601 delay_16,
602 )
603 }
604
605 "interval" => {
606 if parts.len() < 3 {
607 return "Usage: `theory interval <note1> <note2>`\n\nExample: `theory interval C G`".to_string();
608 }
609 let n1 = match note_index(parts[1]) {
610 Some(n) => n,
611 None => return format!("❓ Unknown note: `{}`", parts[1]),
612 };
613 let n2 = match note_index(parts[2]) {
614 Some(n) => n,
615 None => return format!("❓ Unknown note: `{}`", parts[2]),
616 };
617 let semitones = (n2 + 12 - n1) % 12;
618 let name = INTERVAL_NAMES[semitones.min(12)];
619 let desc = match semitones {
620 0 => "Same pitch — root to root.",
621 5 | 7 => "A **perfect** interval — very stable, consonant.",
622 4 | 3 => "A **third** — the building block of chords.",
623 2 | 9 => "A **second/sixth** — melodic colour.",
624 6 => "The **tritone** — maximally tense, unstable.",
625 10 | 11 => "A **seventh** — dominant tension, wants to resolve.",
626 1 | 8 => "A **half-step/minor sixth** — chromatic tension.",
627 _ => "",
628 };
629 format!(
630 "**🎵 {} → {}**\n\n{} ({} semitones)\n\n_{}_",
631 parts[1].to_uppercase(),
632 parts[2].to_uppercase(),
633 name,
634 semitones,
635 desc,
636 )
637 }
638
639 "" => "**theory** subcommands:\n\n\
640 ```\n\
641 theory chord <root>[quality] chord tones (Am, Cmaj7, F#dim)\n\
642 theory scale <root> <mode> scale notes (C major, A dorian)\n\
643 theory bpm <tempo> beat grid + delay times\n\
644 theory interval <note1> <note2> interval name (C G → P5)\n\
645 ```"
646 .to_string(),
647
648 sub => format!(
649 "Unknown theory sub-command `{sub}`. Use: `chord`, `scale`, `bpm`, `interval`."
650 ),
651 }
652 }
653
654 fn cmd_tips(&self, topic: &str) -> String {
655 match topic {
656 "listening" | "listen" => {
657 "**🎧 Listening Tips**\n\n\
658 1. **Dedicated sessions** — close other tabs; active listening ≠ background music\n\
659 2. **FLAC/lossless** — audible difference on good headphones above 256 kbps\n\
660 3. **Headphone break-in** — new drivers need ~50 h to loosen and open up\n\
661 4. **Equal-loudness** — human hearing is non-linear; 75-85 dB SPL is the sweet spot\n\
662 5. **Log what you hear** — use `add song/album` right after; memory fades fast\n\n\
663 _Try: `theory interval C G` to train your ear while listening._".to_string()
664 }
665 "mixing" | "mix" => {
666 "**🎚 Mixing Tips**\n\n\
667 1. **Gain staging first** — keep channel peaks at -18 dBFS before touching EQ\n\
668 2. **Cut before you boost** — subtractive EQ sounds more natural than additive\n\
669 3. **Low-cut everything** — highpass filters below 80-100 Hz clean up mud fast\n\
670 4. **Reference tracks** — A/B with a commercial mix in the same genre every 20 min\n\
671 5. **Rest your ears** — mix for 45 min, break for 15; fatigue kills judgement\n\n\
672 _Use `theory bpm` to calculate delay sync times for your project tempo._".to_string()
673 }
674 "mastering" | "master" => {
675 "**💿 Mastering Tips**\n\n\
676 1. **Leave headroom** — deliver mixes peaking at -6 dBFS for mastering\n\
677 2. **Loudness target** — streaming targets: -14 LUFS (Spotify), -16 LUFS (Apple)\n\
678 3. **True peak ceiling** — set limiter ceiling to -1.0 dBTP to avoid inter-sample clips\n\
679 4. **Compare on multiple systems** — headphones, car, laptop speaker, phone\n\
680 5. **A/B with reference** — match loudness before comparing; louder always sounds better\n\n\
681 _Industry standard: 24-bit / 44.1 kHz for streaming; 96 kHz for archiving._".to_string()
682 }
683 "practice" | "instrument" => {
684 "**🎸 Practice Tips**\n\n\
685 1. **Slow it down** — at 60% speed with a metronome; precision before speed\n\
686 2. **Short daily sessions** — 30 min/day beats 3.5 h on weekends (neurologically)\n\
687 3. **Deliberate practice** — work the hard part, not the parts you already know\n\
688 4. **Record yourself** — playback reveals errors your playing brain masks\n\
689 5. **Learn the theory** — use `theory scale` and `theory chord` to understand what you play\n\n\
690 _Try: `theory chord Am` then `theory scale A minor` to see how they relate._".to_string()
691 }
692 "gear" | "equipment" => {
693 "**🎛 Gear Tips**\n\n\
694 1. **Ears > gear** — a trained ear in a treated room beats expensive gear in a reflective room\n\
695 2. **Acoustic treatment first** — bass traps + broadband panels before any monitor upgrade\n\
696 3. **Interface quality** — the pre-amp in your interface shapes your sound more than plugins\n\
697 4. **Headphones for detail** — open-back for mixing reference, closed-back for tracking\n\
698 5. **Buy used, sell new** — professional gear holds value; buy secondhand and save 40–60%\n\n\
699 _The best gear is the gear you know deeply._".to_string()
700 }
701 _ => {
702 "**WIS Sound Tips** — pick a topic:\n\n\
703 ```\n\
704 tips listening — critical listening habits\n\
705 tips mixing — mix technique and workflow\n\
706 tips mastering — loudness, headroom, targets\n\
707 tips practice — instrument and skill development\n\
708 tips gear — equipment and acoustics\n\
709 ```".to_string()
710 }
711 }
712 }
713
714 fn capitalize(s: &str) -> String {
715 let mut c = s.chars();
716 match c.next() {
717 None => String::new(),
718 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
719 }
720 }
721
722 fn dispatch(&self, text: &str) -> String {
723 let arg = text.strip_prefix("@wis-agent").unwrap_or(text).trim();
724
725 let parts: Vec<&str> = arg.split_whitespace().collect();
726 let cmd = parts.first().copied().unwrap_or("help");
727
728 match cmd {
729 "add" => self.cmd_add(&parts[1..]),
730 "stats" => self.cmd_stats(parts.get(1).copied()),
731 "top" => self.cmd_top(&parts[1..]),
732 "theory" => self.cmd_theory(&parts[1..]),
733 "tips" => self.cmd_tips(parts.get(1).copied().unwrap_or("")),
734 "help" | "" => "**WIS — Sound Expert** 🎧\n\
735 _Waldiez Intelligence Sound_\n\n\
736 ```\n\
737 add song|album|artist \"<title>\" … log music\n\
738 stats [song|album|artist|genre] consumption stats\n\
739 top [n] [song|album|artist] highest rated\n\
740 theory chord <root>[quality] chord tones\n\
741 theory scale <root> <mode> scale notes\n\
742 theory bpm <tempo> beat grid + delay times\n\
743 theory interval <note1> <note2> interval name\n\
744 tips [listening|mixing|mastering| audio advice\n\
745 practice|gear]\n\
746 help this message\n\
747 ```"
748 .to_string(),
749 _ => format!("Unknown command: `{cmd}`. Type `help` for the full command list."),
750 }
751 }
752}
753
754#[async_trait]
757impl Actor for WisAgent {
758 fn id(&self) -> String {
759 self.config.id.clone()
760 }
761 fn name(&self) -> &str {
762 &self.config.name
763 }
764 fn state(&self) -> ActorState {
765 self.state.clone()
766 }
767 fn metrics(&self) -> Arc<ActorMetrics> {
768 Arc::clone(&self.metrics)
769 }
770 fn mailbox(&self) -> mpsc::Sender<Message> {
771 self.mailbox_tx.clone()
772 }
773 fn is_protected(&self) -> bool {
774 self.config.protected
775 }
776
777 async fn on_start(&mut self) -> Result<()> {
778 self.state = ActorState::Running;
779 if let Some(pub_) = &self.publisher {
780 pub_.publish(
781 wactorz_mqtt::topics::spawn(&self.config.id),
782 &serde_json::json!({
783 "agentId": self.config.id,
784 "agentName": self.config.name,
785 "agentType": "sound",
786 "timestampMs": Self::now_ms(),
787 }),
788 );
789 }
790 Ok(())
791 }
792
793 async fn handle_message(&mut self, message: Message) -> Result<()> {
794 use wactorz_core::message::MessageType;
795
796 let content = match &message.payload {
797 MessageType::Text { content } => content.trim().to_string(),
798 MessageType::Task { description, .. } => description.trim().to_string(),
799 _ => return Ok(()),
800 };
801
802 let reply = self.dispatch(&content);
803 self.reply(&reply);
804 Ok(())
805 }
806
807 async fn on_heartbeat(&mut self) -> Result<()> {
808 if let Some(pub_) = &self.publisher {
809 pub_.publish(
810 wactorz_mqtt::topics::heartbeat(&self.config.id),
811 &serde_json::json!({
812 "agentId": self.config.id,
813 "agentName": self.config.name,
814 "state": self.state,
815 "timestampMs": Self::now_ms(),
816 }),
817 );
818 }
819 Ok(())
820 }
821
822 async fn run(&mut self) -> Result<()> {
823 self.on_start().await?;
824
825 let mut rx = self
826 .mailbox_rx
827 .take()
828 .ok_or_else(|| anyhow::anyhow!("WisAgent already running"))?;
829
830 let mut hb = tokio::time::interval(std::time::Duration::from_secs(
831 self.config.heartbeat_interval_secs,
832 ));
833 hb.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
834
835 loop {
836 tokio::select! {
837 biased;
838 msg = rx.recv() => match msg {
839 None => break,
840 Some(m) => {
841 self.metrics.record_received();
842 if let wactorz_core::message::MessageType::Command {
843 command: wactorz_core::message::ActorCommand::Stop,
844 } = &m.payload { break; }
845 match self.handle_message(m).await {
846 Ok(_) => self.metrics.record_processed(),
847 Err(e) => {
848 tracing::error!("[{}] {e}", self.config.name);
849 self.metrics.record_failed();
850 }
851 }
852 }
853 },
854 _ = hb.tick() => {
855 self.metrics.record_heartbeat();
856 if let Err(e) = self.on_heartbeat().await {
857 tracing::error!("[{}] heartbeat: {e}", self.config.name);
858 }
859 }
860 }
861 }
862
863 self.state = ActorState::Stopped;
864 self.on_stop().await
865 }
866}