livepilot 1.9.21 → 1.9.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcpbignore +40 -0
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +47 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/README.md +47 -72
  7. package/bin/livepilot.js +135 -0
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +13 -0
  11. package/livepilot/commands/arrange.md +42 -14
  12. package/livepilot/commands/beat.md +68 -21
  13. package/livepilot/commands/evaluate.md +23 -13
  14. package/livepilot/commands/mix.md +35 -11
  15. package/livepilot/commands/perform.md +31 -19
  16. package/livepilot/commands/sounddesign.md +38 -17
  17. package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
  18. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
  19. package/livepilot/skills/livepilot-core/SKILL.md +60 -4
  20. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
  21. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
  22. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
  23. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
  24. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
  25. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
  26. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
  27. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
  28. package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
  29. package/livepilot/skills/livepilot-core/references/overview.md +4 -4
  30. package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
  31. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
  32. package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
  33. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
  34. package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
  35. package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
  36. package/livepilot/skills/livepilot-release/SKILL.md +15 -15
  37. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
  38. package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
  39. package/livepilot.mcpb +0 -0
  40. package/m4l_device/livepilot_bridge.js +1 -1
  41. package/manifest.json +91 -0
  42. package/mcp_server/__init__.py +1 -1
  43. package/mcp_server/creative_constraints/__init__.py +6 -0
  44. package/mcp_server/creative_constraints/engine.py +277 -0
  45. package/mcp_server/creative_constraints/models.py +75 -0
  46. package/mcp_server/creative_constraints/tools.py +341 -0
  47. package/mcp_server/experiment/__init__.py +6 -0
  48. package/mcp_server/experiment/engine.py +213 -0
  49. package/mcp_server/experiment/models.py +120 -0
  50. package/mcp_server/experiment/tools.py +263 -0
  51. package/mcp_server/hook_hunter/__init__.py +5 -0
  52. package/mcp_server/hook_hunter/analyzer.py +342 -0
  53. package/mcp_server/hook_hunter/models.py +57 -0
  54. package/mcp_server/hook_hunter/tools.py +586 -0
  55. package/mcp_server/memory/taste_graph.py +261 -0
  56. package/mcp_server/memory/tools.py +88 -0
  57. package/mcp_server/mix_engine/critics.py +2 -2
  58. package/mcp_server/mix_engine/models.py +1 -1
  59. package/mcp_server/mix_engine/state_builder.py +2 -2
  60. package/mcp_server/musical_intelligence/__init__.py +8 -0
  61. package/mcp_server/musical_intelligence/detectors.py +421 -0
  62. package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
  63. package/mcp_server/musical_intelligence/tools.py +221 -0
  64. package/mcp_server/preview_studio/__init__.py +5 -0
  65. package/mcp_server/preview_studio/engine.py +280 -0
  66. package/mcp_server/preview_studio/models.py +73 -0
  67. package/mcp_server/preview_studio/tools.py +423 -0
  68. package/mcp_server/runtime/session_kernel.py +96 -0
  69. package/mcp_server/runtime/tools.py +90 -1
  70. package/mcp_server/semantic_moves/__init__.py +13 -0
  71. package/mcp_server/semantic_moves/compiler.py +116 -0
  72. package/mcp_server/semantic_moves/mix_compilers.py +291 -0
  73. package/mcp_server/semantic_moves/mix_moves.py +157 -0
  74. package/mcp_server/semantic_moves/models.py +46 -0
  75. package/mcp_server/semantic_moves/performance_compilers.py +208 -0
  76. package/mcp_server/semantic_moves/performance_moves.py +81 -0
  77. package/mcp_server/semantic_moves/registry.py +32 -0
  78. package/mcp_server/semantic_moves/resolvers.py +126 -0
  79. package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
  80. package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
  81. package/mcp_server/semantic_moves/tools.py +204 -0
  82. package/mcp_server/semantic_moves/transition_compilers.py +222 -0
  83. package/mcp_server/semantic_moves/transition_moves.py +76 -0
  84. package/mcp_server/server.py +10 -0
  85. package/mcp_server/session_continuity/__init__.py +6 -0
  86. package/mcp_server/session_continuity/models.py +86 -0
  87. package/mcp_server/session_continuity/tools.py +230 -0
  88. package/mcp_server/session_continuity/tracker.py +235 -0
  89. package/mcp_server/song_brain/__init__.py +6 -0
  90. package/mcp_server/song_brain/builder.py +477 -0
  91. package/mcp_server/song_brain/models.py +132 -0
  92. package/mcp_server/song_brain/tools.py +294 -0
  93. package/mcp_server/stuckness_detector/__init__.py +5 -0
  94. package/mcp_server/stuckness_detector/detector.py +400 -0
  95. package/mcp_server/stuckness_detector/models.py +66 -0
  96. package/mcp_server/stuckness_detector/tools.py +195 -0
  97. package/mcp_server/tools/_conductor.py +104 -6
  98. package/mcp_server/tools/analyzer.py +1 -1
  99. package/mcp_server/tools/devices.py +34 -0
  100. package/mcp_server/wonder_mode/__init__.py +6 -0
  101. package/mcp_server/wonder_mode/diagnosis.py +84 -0
  102. package/mcp_server/wonder_mode/engine.py +493 -0
  103. package/mcp_server/wonder_mode/session.py +114 -0
  104. package/mcp_server/wonder_mode/tools.py +285 -0
  105. package/package.json +2 -2
  106. package/remote_script/LivePilot/__init__.py +1 -1
  107. package/remote_script/LivePilot/browser.py +4 -1
  108. package/remote_script/LivePilot/devices.py +29 -0
  109. package/remote_script/LivePilot/tracks.py +11 -4
  110. package/scripts/generate_tool_catalog.py +131 -0
@@ -0,0 +1,342 @@
1
+ """Hook Hunter analysis — pure computation, zero I/O.
2
+
3
+ Identifies hooks, ranks candidates, scores phrase impact, and
4
+ detects payoff failures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from collections import Counter
11
+ from typing import Optional
12
+
13
+ from .models import HookCandidate, PayoffFailure, PhraseImpact
14
+
15
+
16
+ # ── Hook detection ────────────────────────────────────────────────
17
+
18
+
19
+ def find_hook_candidates(
20
+ tracks: list[dict],
21
+ motif_data: Optional[dict] = None,
22
+ scene_data: Optional[list[dict]] = None,
23
+ composition: Optional[dict] = None,
24
+ ) -> list[HookCandidate]:
25
+ """Detect and rank hook candidates from session data.
26
+
27
+ Looks for: salient melodic motifs, distinctive rhythmic cells,
28
+ signature timbral textures, recurring harmonic progressions.
29
+ """
30
+ motif_data = motif_data or {}
31
+ scene_data = scene_data or []
32
+ composition = composition or {}
33
+ candidates: list[HookCandidate] = []
34
+
35
+ # 1. Motif-based hooks
36
+ for motif in motif_data.get("motifs", []):
37
+ salience = motif.get("salience", 0)
38
+ recurrence = motif.get("recurrence", 0)
39
+ if salience > 0.2 or recurrence > 0.3:
40
+ candidates.append(HookCandidate(
41
+ hook_id=f"motif_{motif.get('name', 'unknown')}",
42
+ hook_type="melodic",
43
+ description=motif.get("description", motif.get("name", "motif")),
44
+ location=motif.get("location", ""),
45
+ memorability=min(1.0, salience * 1.2),
46
+ recurrence=recurrence,
47
+ contrast_potential=motif.get("contrast", 0.5),
48
+ development_potential=_estimate_development_potential(motif),
49
+ ))
50
+
51
+ # 2. Track-name-based detection (lead, hook, melody, riff)
52
+ hook_keywords = {"lead", "hook", "melody", "riff", "main", "top", "vocal", "synth"}
53
+ for track in tracks:
54
+ name = track.get("name", "").lower()
55
+ if any(kw in name for kw in hook_keywords):
56
+ candidates.append(HookCandidate(
57
+ hook_id=f"track_{name.replace(' ', '_')}",
58
+ hook_type="melodic" if "melody" in name or "vocal" in name else "timbral",
59
+ description=f"Track: {track.get('name', name)}",
60
+ location=track.get("name", ""),
61
+ memorability=0.5,
62
+ recurrence=0.6, # present across scenes typically
63
+ contrast_potential=0.5,
64
+ development_potential=0.6,
65
+ ))
66
+
67
+ # 3. Rhythmic hooks from drum/percussion patterns
68
+ rhythm_keywords = {"drum", "beat", "perc", "hat", "kick", "clap"}
69
+ groove_tracks = [t for t in tracks if any(kw in t.get("name", "").lower() for kw in rhythm_keywords)]
70
+ if groove_tracks:
71
+ # Check for distinctive rhythmic patterns via clip reuse
72
+ clip_reuse = _measure_clip_reuse(scene_data, groove_tracks)
73
+ if clip_reuse > 0.5:
74
+ candidates.append(HookCandidate(
75
+ hook_id="groove_pattern",
76
+ hook_type="rhythmic",
77
+ description="Primary groove pattern",
78
+ location=groove_tracks[0].get("name", "drums"),
79
+ memorability=0.5,
80
+ recurrence=clip_reuse,
81
+ contrast_potential=0.4,
82
+ development_potential=0.5,
83
+ ))
84
+
85
+ # Score all candidates
86
+ for c in candidates:
87
+ c.salience = _compute_salience(c)
88
+
89
+ # Sort by salience
90
+ candidates.sort(key=lambda c: c.salience, reverse=True)
91
+ return candidates
92
+
93
+
94
+ def find_primary_hook(
95
+ tracks: list[dict],
96
+ motif_data: Optional[dict] = None,
97
+ scene_data: Optional[list[dict]] = None,
98
+ composition: Optional[dict] = None,
99
+ ) -> Optional[HookCandidate]:
100
+ """Find the single most salient hook in the session."""
101
+ candidates = find_hook_candidates(tracks, motif_data, scene_data, composition)
102
+ return candidates[0] if candidates else None
103
+
104
+
105
+ # ── Phrase impact scoring ─────────────────────────────────────────
106
+
107
+
108
+ def score_phrase_impact(
109
+ section_data: dict,
110
+ target: str = "hook",
111
+ song_brain: Optional[dict] = None,
112
+ prev_section: Optional[dict] = None,
113
+ ) -> PhraseImpact:
114
+ """Score the emotional impact of a musical phrase/section.
115
+
116
+ Uses contrast, density shift, harmonic support, and energy
117
+ to judge whether the phrase "lands" emotionally.
118
+ """
119
+ song_brain = song_brain or {}
120
+ prev_section = prev_section or {}
121
+
122
+ energy = section_data.get("energy", 0.5)
123
+ prev_energy = prev_section.get("energy", 0.5)
124
+ density = section_data.get("density", 0.5)
125
+ prev_density = prev_section.get("density", 0.5)
126
+
127
+ # Arrival: big energy jump = strong arrival
128
+ energy_delta = energy - prev_energy
129
+ arrival = min(1.0, max(0.0, energy_delta * 2 + 0.3))
130
+
131
+ # Anticipation: was there a dip before?
132
+ anticipation = min(1.0, max(0.0, (0.5 - prev_energy) * 2)) if prev_energy < 0.5 else 0.2
133
+
134
+ # Contrast: density or energy change
135
+ contrast = min(1.0, abs(density - prev_density) + abs(energy_delta))
136
+
137
+ # Repetition fatigue: high density with no change = fatiguing
138
+ fatigue = max(0.0, 1.0 - contrast) * 0.5
139
+
140
+ # Section clarity: does it have a clear role?
141
+ clarity = 0.7 if section_data.get("label") else 0.3
142
+
143
+ # Groove continuity: rhythm present
144
+ groove = 0.7 if section_data.get("has_drums", True) else 0.3
145
+
146
+ # Payoff balance
147
+ payoff = min(1.0, (arrival + anticipation) / 2)
148
+
149
+ # Composite — target-specific weighting
150
+ weights = _get_target_weights(target)
151
+ composite = (
152
+ arrival * weights.get("arrival", 0.2)
153
+ + anticipation * weights.get("anticipation", 0.15)
154
+ + contrast * weights.get("contrast", 0.2)
155
+ + (1.0 - fatigue) * weights.get("freshness", 0.1)
156
+ + clarity * weights.get("clarity", 0.1)
157
+ + groove * weights.get("groove", 0.1)
158
+ + payoff * weights.get("payoff", 0.15)
159
+ )
160
+
161
+ section_id = section_data.get("id", section_data.get("name", ""))
162
+
163
+ return PhraseImpact(
164
+ phrase_id=f"phrase_{hashlib.sha256(str(section_id).encode()).hexdigest()[:8]}",
165
+ target=target,
166
+ arrival_strength=round(arrival, 3),
167
+ anticipation_strength=round(anticipation, 3),
168
+ contrast_quality=round(contrast, 3),
169
+ repetition_fatigue=round(fatigue, 3),
170
+ section_clarity=round(clarity, 3),
171
+ groove_continuity=round(groove, 3),
172
+ payoff_balance=round(payoff, 3),
173
+ composite_impact=round(composite, 3),
174
+ )
175
+
176
+
177
+ def compare_phrase_impacts(
178
+ impacts: list[PhraseImpact],
179
+ ) -> list[dict]:
180
+ """Rank multiple phrase impacts by composite score."""
181
+ ranked = sorted(impacts, key=lambda i: i.composite_impact, reverse=True)
182
+ return [
183
+ {
184
+ "rank": idx + 1,
185
+ "phrase_id": imp.phrase_id,
186
+ "target": imp.target,
187
+ "composite_impact": imp.composite_impact,
188
+ "arrival_strength": imp.arrival_strength,
189
+ "contrast_quality": imp.contrast_quality,
190
+ }
191
+ for idx, imp in enumerate(ranked)
192
+ ]
193
+
194
+
195
+ # ── Payoff failure detection ─────────────────────────────────────
196
+
197
+
198
+ def detect_payoff_failures(
199
+ sections: list[dict],
200
+ song_brain: Optional[dict] = None,
201
+ ) -> list[PayoffFailure]:
202
+ """Detect sections that should deliver a payoff but don't."""
203
+ song_brain = song_brain or {}
204
+ payoff_targets = song_brain.get("payoff_targets", [])
205
+ failures: list[PayoffFailure] = []
206
+
207
+ for i, section in enumerate(sections):
208
+ section_id = section.get("id", section.get("name", f"section_{i}"))
209
+ label = section.get("label", "").lower()
210
+ energy = section.get("energy", 0.5)
211
+ prev_energy = sections[i - 1].get("energy", 0.5) if i > 0 else 0.3
212
+
213
+ is_payoff = (
214
+ section_id in payoff_targets
215
+ or label in ("chorus", "drop", "hook")
216
+ or section.get("is_payoff", False)
217
+ )
218
+
219
+ if not is_payoff:
220
+ continue
221
+
222
+ # Check for flat arrival (no energy increase)
223
+ if energy - prev_energy < 0.1:
224
+ failures.append(PayoffFailure(
225
+ section_id=section_id,
226
+ expected_target=label or "payoff",
227
+ failure_type="flat_arrival",
228
+ severity=0.6,
229
+ suggestion="Increase energy contrast — try subtracting before the payoff section",
230
+ ))
231
+
232
+ # Check for weak contrast (only if flat_arrival didn't already fire)
233
+ elif i > 0 and abs(energy - prev_energy) < 0.05:
234
+ failures.append(PayoffFailure(
235
+ section_id=section_id,
236
+ expected_target=label or "payoff",
237
+ failure_type="weak_contrast",
238
+ severity=0.5,
239
+ suggestion="Add density or timbral contrast leading into this section",
240
+ ))
241
+
242
+ return failures
243
+
244
+
245
+ def suggest_payoff_repairs(
246
+ failures: list[PayoffFailure],
247
+ ) -> list[dict]:
248
+ """Generate repair suggestions for payoff failures."""
249
+ repairs = []
250
+ for f in failures:
251
+ repair = {
252
+ "section_id": f.section_id,
253
+ "failure_type": f.failure_type,
254
+ "severity": f.severity,
255
+ "suggestion": f.suggestion,
256
+ }
257
+
258
+ # Add specific repair strategies
259
+ if f.failure_type == "flat_arrival":
260
+ repair["strategies"] = [
261
+ "Add a 2-4 bar breakdown before this section",
262
+ "Use a filter sweep or riser to build anticipation",
263
+ "Strip elements in the preceding section to create contrast",
264
+ ]
265
+ elif f.failure_type == "weak_contrast":
266
+ repair["strategies"] = [
267
+ "Increase track count or add a new element at the payoff",
268
+ "Change the harmonic content (key change, chord substitution)",
269
+ "Add rhythmic variation (double-time feel, new percussion)",
270
+ ]
271
+ elif f.failure_type == "no_setup":
272
+ repair["strategies"] = [
273
+ "Add a buildup section before the payoff",
274
+ "Use automation to create a gradual energy ramp",
275
+ ]
276
+ else:
277
+ repair["strategies"] = [f.suggestion]
278
+
279
+ repairs.append(repair)
280
+
281
+ return repairs
282
+
283
+
284
+ # ── Helpers ───────────────────────────────────────────────────────
285
+
286
+
287
+ def _compute_salience(c: HookCandidate) -> float:
288
+ """Compute composite salience score for a hook candidate."""
289
+ return round(
290
+ c.memorability * 0.35
291
+ + c.recurrence * 0.25
292
+ + c.contrast_potential * 0.2
293
+ + c.development_potential * 0.2,
294
+ 3,
295
+ )
296
+
297
+
298
+ def _estimate_development_potential(motif: dict) -> float:
299
+ """Estimate how much room a motif has for development."""
300
+ # Simple heuristic: shorter motifs have more development room
301
+ length = motif.get("length_beats", 4)
302
+ if length <= 2:
303
+ return 0.8
304
+ elif length <= 4:
305
+ return 0.6
306
+ elif length <= 8:
307
+ return 0.4
308
+ return 0.3
309
+
310
+
311
+ def _measure_clip_reuse(scene_data: list[dict], target_tracks: list[dict]) -> float:
312
+ """Measure how much clips are reused across scenes for target tracks."""
313
+ if not scene_data:
314
+ return 0.0
315
+
316
+ target_names = {t.get("name", "").lower() for t in target_tracks}
317
+ clip_names = Counter()
318
+
319
+ for scene in scene_data:
320
+ for clip in scene.get("clips", []):
321
+ clip_name = clip.get("name", "") if isinstance(clip, dict) else str(clip)
322
+ track_name = clip.get("track", "") if isinstance(clip, dict) else ""
323
+ if track_name.lower() in target_names and clip_name:
324
+ clip_names[clip_name] += 1
325
+
326
+ if not clip_names:
327
+ return 0.0
328
+
329
+ max_reuse = max(clip_names.values())
330
+ return min(1.0, max_reuse / max(len(scene_data), 1))
331
+
332
+
333
+ def _get_target_weights(target: str) -> dict:
334
+ """Get scoring weights based on target type."""
335
+ presets = {
336
+ "hook": {"arrival": 0.15, "anticipation": 0.1, "contrast": 0.2, "freshness": 0.15, "clarity": 0.1, "groove": 0.1, "payoff": 0.2},
337
+ "drop": {"arrival": 0.3, "anticipation": 0.2, "contrast": 0.2, "freshness": 0.05, "clarity": 0.05, "groove": 0.1, "payoff": 0.1},
338
+ "chorus": {"arrival": 0.2, "anticipation": 0.15, "contrast": 0.15, "freshness": 0.1, "clarity": 0.15, "groove": 0.1, "payoff": 0.15},
339
+ "transition": {"arrival": 0.1, "anticipation": 0.1, "contrast": 0.3, "freshness": 0.1, "clarity": 0.1, "groove": 0.15, "payoff": 0.15},
340
+ "loop": {"arrival": 0.05, "anticipation": 0.05, "contrast": 0.1, "freshness": 0.25, "clarity": 0.1, "groove": 0.3, "payoff": 0.15},
341
+ }
342
+ return presets.get(target, presets["hook"])
@@ -0,0 +1,57 @@
1
+ """Hook Hunter data models — pure dataclasses, zero I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass
10
+ class HookCandidate:
11
+ """A potential hook element in the track."""
12
+
13
+ hook_id: str = ""
14
+ hook_type: str = "" # "melodic", "rhythmic", "timbral", "harmonic", "textural"
15
+ description: str = ""
16
+ location: str = "" # track/clip reference
17
+ memorability: float = 0.0 # 0-1 how catchy/memorable
18
+ recurrence: float = 0.0 # 0-1 how often it appears
19
+ contrast_potential: float = 0.0 # 0-1 how well it stands out
20
+ development_potential: float = 0.0 # 0-1 how much room to develop
21
+ salience: float = 0.0 # composite score
22
+
23
+ def to_dict(self) -> dict:
24
+ return asdict(self)
25
+
26
+
27
+ @dataclass
28
+ class PhraseImpact:
29
+ """Phrase-level emotional impact scoring."""
30
+
31
+ phrase_id: str = ""
32
+ target: str = "" # "hook", "drop", "chorus", "transition", "loop"
33
+ arrival_strength: float = 0.0 # 0-1 does it feel like an arrival?
34
+ anticipation_strength: float = 0.0 # 0-1 does the setup work?
35
+ contrast_quality: float = 0.0 # 0-1 is there enough change?
36
+ repetition_fatigue: float = 0.0 # 0-1 is it overused?
37
+ section_clarity: float = 0.0 # 0-1 is the section role clear?
38
+ groove_continuity: float = 0.0 # 0-1 does the groove carry through?
39
+ payoff_balance: float = 0.0 # 0-1 setup vs payoff balance
40
+ composite_impact: float = 0.0 # weighted aggregate
41
+
42
+ def to_dict(self) -> dict:
43
+ return asdict(self)
44
+
45
+
46
+ @dataclass
47
+ class PayoffFailure:
48
+ """A detected payoff failure — where the song should deliver but doesn't."""
49
+
50
+ section_id: str = ""
51
+ expected_target: str = "" # "drop", "chorus", "hook"
52
+ failure_type: str = "" # "flat_arrival", "weak_contrast", "no_setup", "hook_absent"
53
+ severity: float = 0.0 # 0-1
54
+ suggestion: str = ""
55
+
56
+ def to_dict(self) -> dict:
57
+ return asdict(self)