wactorz_agents/
wis_agent.rs

1//! Sound-expert agent — **WIS** (Waldiez Intelligence Sound).
2//!
3//! Tracks music listening (songs, albums, artists), provides built-in music
4//! theory tools (chords, scales, BPM analysis, intervals), and dispenses
5//! expert audio tips. No external APIs required.
6//!
7//! NATO node: **whiskey** (W → WIS)
8//!
9//! ## Usage (via IO bar)
10//!
11//! ```text
12//! @wis-agent add song "Clair de Lune" Debussy 10 classical
13//! @wis-agent add album "Kind of Blue" "Miles Davis" 9.5 jazz
14//! @wis-agent add artist Bach 10 classical
15//! @wis-agent stats [song|album|artist|genre]
16//! @wis-agent top [n] [song|album|artist]
17//! @wis-agent theory chord Am              → A C E (minor triad)
18//! @wis-agent theory chord Cmaj7           → C E G B
19//! @wis-agent theory scale "C major"       → C D E F G A B
20//! @wis-agent theory scale "A dorian"      → A B C D E F# G
21//! @wis-agent theory bpm 128               → Allegro · beat grid
22//! @wis-agent theory interval C G          → Perfect 5th (7 semitones)
23//! @wis-agent tips listening|mixing|mastering|practice|gear
24//! @wis-agent help
25//! ```
26//!
27//! All data is held in memory — restarting the agent resets the log.
28
29use 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
40// ── Music theory constants ──────────────────────────────────────────────────────
41
42const 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
85/// Parse a note token like "C", "C#", "Db", "F#".
86/// Returns (note_index, remaining_suffix).
87fn parse_root(token: &str) -> Option<(usize, &str)> {
88    // Try 2-char root first (C#, Db, …)
89    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    // Fall back to 1-char root
96    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// ── Data model ──────────────────────────────────────────────────────────────────
164
165#[derive(Clone)]
166struct MusicEntry {
167    title: String,
168    entry_type: String, // "song" | "album" | "artist"
169    artist: Option<String>,
170    rating: Option<f64>,
171    genre: Option<String>,
172    #[expect(dead_code)]
173    ts_ms: u64,
174}
175
176// ── WisAgent ────────────────────────────────────────────────────────────────────
177
178pub 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    // ── Command handlers ────────────────────────────────────────────────────────
229
230    fn cmd_add(&self, parts: &[&str]) -> String {
231        // add <song|album|artist> "<title>" [artist] [rating] [genre]
232        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        // Parse (possibly quoted) title
247        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        // For song/album: [artist] [rating] [genre]
267        // For artist:     [rating] [genre]
268        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                    // First rem token: artist if it doesn't parse as a rating
277                    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                // treat as genre filter
332                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    // ── Music theory ────────────────────────────────────────────────────────────
474
475    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                // Parts 1..N form the chord token (may be space-separated like "C maj7")
488                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                // Mode = suffix of root token + remaining parts
535                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                // Delay times useful for production
578                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// ── Actor implementation ────────────────────────────────────────────────────────
755
756#[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}