livepilot 1.9.23 → 1.10.0
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.
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +119 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +144 -13
- package/bin/livepilot.js +87 -0
- package/installer/codex.js +147 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +21 -4
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
- package/livepilot/skills/livepilot-core/references/overview.md +13 -9
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
- package/livepilot/skills/livepilot-devices/SKILL.md +16 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +19 -5
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +104 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +15 -0
- package/livepilot.mcpb +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/manifest.json +2 -2
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +357 -0
- package/mcp_server/atlas/device_atlas.json +44067 -0
- package/mcp_server/atlas/enrichments/__init__.py +111 -0
- package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
- package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
- package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
- package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
- package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
- package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
- package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
- package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
- package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
- package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
- package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
- package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
- package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
- package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
- package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
- package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
- package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
- package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
- package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
- package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
- package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
- package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
- package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
- package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
- package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
- package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
- package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
- package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
- package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
- package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
- package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
- package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
- package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
- package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
- package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
- package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
- package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
- package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
- package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
- package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
- package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
- package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
- package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
- package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
- package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
- package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
- package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
- package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
- package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
- package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
- package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
- package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
- package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
- package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
- package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
- package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
- package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
- package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
- package/mcp_server/atlas/scanner.py +236 -0
- package/mcp_server/atlas/tools.py +224 -0
- package/mcp_server/composer/__init__.py +1 -0
- package/mcp_server/composer/engine.py +452 -0
- package/mcp_server/composer/layer_planner.py +427 -0
- package/mcp_server/composer/prompt_parser.py +329 -0
- package/mcp_server/composer/tools.py +201 -0
- package/mcp_server/connection.py +53 -8
- package/mcp_server/corpus/__init__.py +377 -0
- package/mcp_server/device_forge/__init__.py +1 -0
- package/mcp_server/device_forge/builder.py +377 -0
- package/mcp_server/device_forge/models.py +142 -0
- package/mcp_server/device_forge/templates.py +483 -0
- package/mcp_server/device_forge/tools.py +162 -0
- package/mcp_server/hook_hunter/analyzer.py +23 -0
- package/mcp_server/hook_hunter/models.py +1 -0
- package/mcp_server/hook_hunter/tools.py +4 -2
- package/mcp_server/m4l_bridge.py +1 -0
- package/mcp_server/memory/taste_graph.py +68 -1
- package/mcp_server/memory/tools.py +15 -4
- package/mcp_server/musical_intelligence/detectors.py +14 -1
- package/mcp_server/musical_intelligence/tools.py +11 -8
- package/mcp_server/persistence/__init__.py +1 -0
- package/mcp_server/persistence/base_store.py +82 -0
- package/mcp_server/persistence/project_store.py +106 -0
- package/mcp_server/persistence/taste_store.py +122 -0
- package/mcp_server/preview_studio/models.py +1 -0
- package/mcp_server/preview_studio/tools.py +56 -13
- package/mcp_server/runtime/capability.py +66 -0
- package/mcp_server/runtime/capability_probe.py +137 -0
- package/mcp_server/runtime/execution_router.py +143 -0
- package/mcp_server/runtime/live_version.py +102 -0
- package/mcp_server/runtime/remote_commands.py +87 -0
- package/mcp_server/runtime/tools.py +18 -4
- package/mcp_server/sample_engine/__init__.py +1 -0
- package/mcp_server/sample_engine/analyzer.py +216 -0
- package/mcp_server/sample_engine/critics.py +390 -0
- package/mcp_server/sample_engine/models.py +193 -0
- package/mcp_server/sample_engine/moves.py +127 -0
- package/mcp_server/sample_engine/planner.py +186 -0
- package/mcp_server/sample_engine/sources.py +540 -0
- package/mcp_server/sample_engine/techniques.py +908 -0
- package/mcp_server/sample_engine/tools.py +442 -0
- package/mcp_server/semantic_moves/__init__.py +3 -0
- package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
- package/mcp_server/semantic_moves/mix_moves.py +41 -41
- package/mcp_server/semantic_moves/performance_moves.py +13 -13
- package/mcp_server/semantic_moves/sample_compilers.py +372 -0
- package/mcp_server/semantic_moves/sound_design_moves.py +15 -15
- package/mcp_server/semantic_moves/tools.py +18 -17
- package/mcp_server/semantic_moves/transition_moves.py +16 -16
- package/mcp_server/server.py +51 -0
- package/mcp_server/services/__init__.py +1 -0
- package/mcp_server/services/motif_service.py +67 -0
- package/mcp_server/session_continuity/tracker.py +29 -1
- package/mcp_server/song_brain/builder.py +28 -1
- package/mcp_server/song_brain/models.py +4 -0
- package/mcp_server/song_brain/tools.py +20 -2
- package/mcp_server/sound_design/critics.py +89 -1
- package/mcp_server/splice_client/__init__.py +1 -0
- package/mcp_server/splice_client/client.py +347 -0
- package/mcp_server/splice_client/models.py +96 -0
- package/mcp_server/splice_client/protos/__init__.py +1 -0
- package/mcp_server/splice_client/protos/app_pb2.py +319 -0
- package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
- package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
- package/mcp_server/tools/arrangement.py +69 -0
- package/mcp_server/tools/automation.py +15 -2
- package/mcp_server/tools/devices.py +117 -6
- package/mcp_server/tools/notes.py +37 -4
- package/mcp_server/wonder_mode/diagnosis.py +5 -0
- package/mcp_server/wonder_mode/engine.py +85 -1
- package/mcp_server/wonder_mode/tools.py +6 -1
- package/package.json +12 -2
- package/remote_script/LivePilot/__init__.py +8 -1
- package/remote_script/LivePilot/arrangement.py +114 -0
- package/remote_script/LivePilot/browser.py +56 -1
- package/remote_script/LivePilot/devices.py +236 -6
- package/remote_script/LivePilot/mixing.py +8 -3
- package/remote_script/LivePilot/server.py +5 -1
- package/remote_script/LivePilot/transport.py +3 -0
- package/remote_script/LivePilot/version_detect.py +78 -0
- package/scripts/sync_metadata.py +132 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""SampleAnalyzer — filename parsing, material classification, mode recommendation.
|
|
2
|
+
|
|
3
|
+
Pure computation for the offline parts. Spectral analysis requires M4L bridge
|
|
4
|
+
and is handled in tools.py which calls these functions + bridge data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .models import SampleProfile
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── Filename Metadata Parsing ───────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
# Key patterns: C, Cm, C#, C#m, Cb, Cbm, Csharp, Csharpmin, etc.
|
|
19
|
+
_KEY_PATTERN = re.compile(
|
|
20
|
+
r'\b([A-G])([b#]|sharp|flat)?(m|min|minor|maj|major)?\b',
|
|
21
|
+
re.IGNORECASE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# BPM patterns: 120bpm, 120_bpm, 120 BPM, or standalone 60-300 range
|
|
25
|
+
_BPM_PATTERN = re.compile(
|
|
26
|
+
r'\b(\d{2,3})\s*(?:bpm)\b', re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
_BPM_STANDALONE = re.compile(
|
|
29
|
+
r'(?:^|[_\-\s])(\d{2,3})(?:[_\-\s]|$)',
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_KEY_NORMALIZE = {
|
|
33
|
+
"sharp": "#", "flat": "b",
|
|
34
|
+
"min": "m", "minor": "m", "maj": "", "major": "",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_filename_metadata(filename: str) -> dict:
|
|
39
|
+
"""Extract key and BPM from a filename string.
|
|
40
|
+
|
|
41
|
+
Returns dict with 'key' (str|None) and 'bpm' (float|None).
|
|
42
|
+
"""
|
|
43
|
+
stem = os.path.splitext(os.path.basename(filename))[0]
|
|
44
|
+
# Replace common separators with spaces for easier matching
|
|
45
|
+
normalized = stem.replace("-", " ").replace("_", " ")
|
|
46
|
+
|
|
47
|
+
key = _extract_key(normalized)
|
|
48
|
+
bpm = _extract_bpm(normalized)
|
|
49
|
+
|
|
50
|
+
return {"key": key, "bpm": bpm}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_key(text: str) -> Optional[str]:
|
|
54
|
+
"""Extract musical key from text."""
|
|
55
|
+
matches = list(_KEY_PATTERN.finditer(text))
|
|
56
|
+
for match in matches:
|
|
57
|
+
root = match.group(1).upper()
|
|
58
|
+
accidental = match.group(2) or ""
|
|
59
|
+
quality = match.group(3) or ""
|
|
60
|
+
|
|
61
|
+
# Normalize accidentals
|
|
62
|
+
accidental = _KEY_NORMALIZE.get(accidental.lower(), accidental)
|
|
63
|
+
quality = _KEY_NORMALIZE.get(quality.lower(), quality) if quality else ""
|
|
64
|
+
|
|
65
|
+
# Avoid false positives: single letters that are common words
|
|
66
|
+
full = root + accidental + quality
|
|
67
|
+
if len(full) == 1 and root in ("A", "B", "C", "D", "E", "F", "G"):
|
|
68
|
+
# Single letter — only accept if it looks like it's in a key context
|
|
69
|
+
# Check surrounding chars
|
|
70
|
+
start = match.start()
|
|
71
|
+
end = match.end()
|
|
72
|
+
before = text[start - 1] if start > 0 else " "
|
|
73
|
+
after = text[end] if end < len(text) else " "
|
|
74
|
+
if before.isalpha() or after.isalpha():
|
|
75
|
+
continue # Part of a word, not a key
|
|
76
|
+
return full
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _extract_bpm(text: str) -> Optional[float]:
|
|
81
|
+
"""Extract BPM from text."""
|
|
82
|
+
# Try explicit bpm markers first
|
|
83
|
+
match = _BPM_PATTERN.search(text)
|
|
84
|
+
if match:
|
|
85
|
+
bpm = float(match.group(1))
|
|
86
|
+
if 40 <= bpm <= 300:
|
|
87
|
+
return bpm
|
|
88
|
+
|
|
89
|
+
# Try standalone numbers in valid range
|
|
90
|
+
for match in _BPM_STANDALONE.finditer(text):
|
|
91
|
+
bpm = float(match.group(1))
|
|
92
|
+
if 60 <= bpm <= 250:
|
|
93
|
+
return bpm
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Material Classification ─────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
_MATERIAL_KEYWORDS: dict[str, list[str]] = {
|
|
100
|
+
"vocal": ["vocal", "vox", "voice", "singer", "acapella", "spoken"],
|
|
101
|
+
"drum_loop": ["drum", "beat", "break", "breakbeat", "loop", "groove",
|
|
102
|
+
"hihat", "hat", "ride", "cymbal", "perc", "percussion",
|
|
103
|
+
"shaker", "tamb", "conga", "bongo", "top"],
|
|
104
|
+
"one_shot": ["kick", "snare", "clap", "snap", "tom", "rim", "hit",
|
|
105
|
+
"oneshot", "one shot", "stab", "shot", "impact"],
|
|
106
|
+
"instrument_loop": ["guitar", "piano", "keys", "bass", "synth",
|
|
107
|
+
"strings", "brass", "horn", "organ", "riff",
|
|
108
|
+
"chord", "arp", "pluck"],
|
|
109
|
+
"texture": ["ambient", "pad", "drone", "atmosphere", "noise",
|
|
110
|
+
"texture", "wash", "evolving", "soundscape"],
|
|
111
|
+
"foley": ["foley", "field", "recording", "room", "nature",
|
|
112
|
+
"water", "metal", "wood", "glass", "paper"],
|
|
113
|
+
"fx": ["fx", "effect", "riser", "sweep", "whoosh", "boom",
|
|
114
|
+
"transition", "downlifter", "uplifter"],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def classify_material_from_name(name: str) -> str:
|
|
119
|
+
"""Classify sample material type from filename/name keywords."""
|
|
120
|
+
lower = name.lower().replace("-", " ").replace("_", " ")
|
|
121
|
+
|
|
122
|
+
# Score each type by keyword matches
|
|
123
|
+
scores: dict[str, int] = {}
|
|
124
|
+
for material_type, keywords in _MATERIAL_KEYWORDS.items():
|
|
125
|
+
score = sum(1 for kw in keywords if kw in lower)
|
|
126
|
+
if score > 0:
|
|
127
|
+
scores[material_type] = score
|
|
128
|
+
|
|
129
|
+
if not scores:
|
|
130
|
+
return "unknown"
|
|
131
|
+
|
|
132
|
+
return max(scores, key=scores.get)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── Simpler Mode Recommendation ────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def suggest_simpler_mode(profile: SampleProfile) -> str:
|
|
139
|
+
"""Recommend Simpler playback mode based on material analysis.
|
|
140
|
+
|
|
141
|
+
Returns: "classic", "one_shot", or "slice"
|
|
142
|
+
"""
|
|
143
|
+
if profile.duration_seconds < 0.5 or profile.material_type == "one_shot":
|
|
144
|
+
return "classic"
|
|
145
|
+
if profile.material_type == "fx":
|
|
146
|
+
return "classic"
|
|
147
|
+
if profile.material_type in ("texture", "foley"):
|
|
148
|
+
return "classic"
|
|
149
|
+
if profile.material_type in ("drum_loop", "instrument_loop",
|
|
150
|
+
"vocal", "full_mix"):
|
|
151
|
+
return "slice"
|
|
152
|
+
# Unknown material with decent length — slice is more useful
|
|
153
|
+
if profile.duration_seconds > 2.0:
|
|
154
|
+
return "slice"
|
|
155
|
+
return "classic"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def suggest_slice_method(profile: SampleProfile) -> str:
|
|
159
|
+
"""Recommend slice-by method for Simpler's Slice mode."""
|
|
160
|
+
if profile.material_type == "drum_loop":
|
|
161
|
+
return "transient"
|
|
162
|
+
if profile.material_type == "instrument_loop":
|
|
163
|
+
return "beat"
|
|
164
|
+
if profile.material_type == "vocal":
|
|
165
|
+
return "region"
|
|
166
|
+
if profile.material_type == "full_mix":
|
|
167
|
+
return "beat"
|
|
168
|
+
return "transient"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def suggest_warp_mode(profile: SampleProfile) -> str:
|
|
172
|
+
"""Recommend Ableton warp mode for the sample material."""
|
|
173
|
+
mode_map = {
|
|
174
|
+
"drum_loop": "beats",
|
|
175
|
+
"one_shot": "complex",
|
|
176
|
+
"instrument_loop": "complex_pro",
|
|
177
|
+
"vocal": "complex_pro",
|
|
178
|
+
"texture": "texture",
|
|
179
|
+
"foley": "texture",
|
|
180
|
+
"fx": "complex",
|
|
181
|
+
"full_mix": "complex_pro",
|
|
182
|
+
}
|
|
183
|
+
return mode_map.get(profile.material_type, "complex")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def build_profile_from_filename(
|
|
187
|
+
file_path: str,
|
|
188
|
+
source: str = "filesystem",
|
|
189
|
+
duration_seconds: float = 0.0,
|
|
190
|
+
) -> SampleProfile:
|
|
191
|
+
"""Build a SampleProfile from filename metadata only (no spectral analysis).
|
|
192
|
+
|
|
193
|
+
This is the fallback when M4L bridge is unavailable.
|
|
194
|
+
"""
|
|
195
|
+
name = os.path.splitext(os.path.basename(file_path))[0]
|
|
196
|
+
metadata = parse_filename_metadata(file_path)
|
|
197
|
+
material = classify_material_from_name(name)
|
|
198
|
+
|
|
199
|
+
profile = SampleProfile(
|
|
200
|
+
source=source,
|
|
201
|
+
file_path=file_path,
|
|
202
|
+
name=name,
|
|
203
|
+
key=metadata.get("key"),
|
|
204
|
+
key_confidence=0.5 if metadata.get("key") else 0.0,
|
|
205
|
+
bpm=metadata.get("bpm"),
|
|
206
|
+
bpm_confidence=0.5 if metadata.get("bpm") else 0.0,
|
|
207
|
+
material_type=material,
|
|
208
|
+
material_confidence=0.4, # filename-only is low confidence
|
|
209
|
+
duration_seconds=duration_seconds,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
profile.suggested_mode = suggest_simpler_mode(profile)
|
|
213
|
+
profile.suggested_slice_by = suggest_slice_method(profile)
|
|
214
|
+
profile.suggested_warp_mode = suggest_warp_mode(profile)
|
|
215
|
+
|
|
216
|
+
return profile
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Sample Engine critics — score sample fitness against the current song.
|
|
2
|
+
|
|
3
|
+
Six critics: key_fit, tempo_fit, frequency_fit, role_fit, vibe_fit, intent_fit.
|
|
4
|
+
All pure computation, zero I/O. Scores are 0.0-1.0 continuous (not issue-detection).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .models import CriticResult, SampleProfile, SampleIntent
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Music Theory Helpers ────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
_NOTE_TO_NUM = {
|
|
17
|
+
"C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3,
|
|
18
|
+
"E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8,
|
|
19
|
+
"Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_key_to_num(key_str: str) -> tuple[int, bool]:
|
|
24
|
+
"""Parse key string to (pitch_class, is_minor)."""
|
|
25
|
+
if not key_str:
|
|
26
|
+
return (-1, False)
|
|
27
|
+
# Strip quality suffixes properly (not char-by-char rstrip)
|
|
28
|
+
s = key_str
|
|
29
|
+
is_minor = False
|
|
30
|
+
for suffix in ("minor", "min", "major", "maj"):
|
|
31
|
+
if s.endswith(suffix):
|
|
32
|
+
is_minor = suffix in ("minor", "min")
|
|
33
|
+
s = s[:-len(suffix)]
|
|
34
|
+
break
|
|
35
|
+
if s.endswith("m"):
|
|
36
|
+
is_minor = True
|
|
37
|
+
s = s[:-1]
|
|
38
|
+
root = s
|
|
39
|
+
num = _NOTE_TO_NUM.get(root, -1)
|
|
40
|
+
return (num, is_minor)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _key_distance(key_a: str, key_b: str) -> int:
|
|
44
|
+
"""Compute musical distance between two keys (0-6 on circle of fifths)."""
|
|
45
|
+
num_a, minor_a = _parse_key_to_num(key_a)
|
|
46
|
+
num_b, minor_b = _parse_key_to_num(key_b)
|
|
47
|
+
if num_a < 0 or num_b < 0:
|
|
48
|
+
return 7 # unknown
|
|
49
|
+
|
|
50
|
+
# Convert minor to relative major for comparison
|
|
51
|
+
if minor_a:
|
|
52
|
+
num_a = (num_a + 3) % 12
|
|
53
|
+
if minor_b:
|
|
54
|
+
num_b = (num_b + 3) % 12
|
|
55
|
+
|
|
56
|
+
# Circle of fifths distance
|
|
57
|
+
diff = (num_a - num_b) % 12
|
|
58
|
+
fifths = min(
|
|
59
|
+
_count_fifths(diff),
|
|
60
|
+
_count_fifths(12 - diff),
|
|
61
|
+
)
|
|
62
|
+
return fifths
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _count_fifths(semitones: int) -> int:
|
|
66
|
+
"""Count steps on circle of fifths for a given semitone interval."""
|
|
67
|
+
# Map: 0->0, 7->1, 2->2, 9->3, 4->4, 11->5, 6->6
|
|
68
|
+
fifths_map = {0: 0, 7: 1, 2: 2, 9: 3, 4: 4, 11: 5, 6: 6,
|
|
69
|
+
5: 1, 10: 2, 3: 3, 8: 4, 1: 5}
|
|
70
|
+
return fifths_map.get(semitones % 12, 6)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Critics ─────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_key_fit_critic(
|
|
77
|
+
profile: SampleProfile,
|
|
78
|
+
song_key: Optional[str] = None,
|
|
79
|
+
) -> CriticResult:
|
|
80
|
+
"""Score how well the sample's key fits the song."""
|
|
81
|
+
if profile.key is None:
|
|
82
|
+
return CriticResult(
|
|
83
|
+
critic_name="key_fit", score=0.0,
|
|
84
|
+
recommendation="Key unknown — verify by ear",
|
|
85
|
+
)
|
|
86
|
+
if song_key is None:
|
|
87
|
+
return CriticResult(
|
|
88
|
+
critic_name="key_fit", score=0.5,
|
|
89
|
+
recommendation="Song key unknown — cannot evaluate fit",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
dist = _key_distance(profile.key, song_key)
|
|
93
|
+
# Score: 0 fifths = 1.0, 1 = 0.85, 2 = 0.7, 3 = 0.55, 4 = 0.4, 5+ = 0.3
|
|
94
|
+
score_map = {0: 1.0, 1: 0.85, 2: 0.7, 3: 0.55, 4: 0.4, 5: 0.3, 6: 0.25}
|
|
95
|
+
score = score_map.get(dist, 0.2)
|
|
96
|
+
|
|
97
|
+
if score >= 0.8:
|
|
98
|
+
rec = "Key matches well — load directly"
|
|
99
|
+
elif score >= 0.6:
|
|
100
|
+
rec = f"Closely related key — works for most intents"
|
|
101
|
+
elif score >= 0.4:
|
|
102
|
+
semitones = _suggest_transpose(profile.key, song_key)
|
|
103
|
+
rec = f"Distant key — transpose {semitones:+d} semitones or use as texture"
|
|
104
|
+
else:
|
|
105
|
+
rec = "Chromatic clash — use with heavy filtering or as intentional tension"
|
|
106
|
+
|
|
107
|
+
return CriticResult(critic_name="key_fit", score=score, recommendation=rec)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _suggest_transpose(from_key: str, to_key: str) -> int:
|
|
111
|
+
"""Suggest semitone transpose to match target key."""
|
|
112
|
+
num_from, _ = _parse_key_to_num(from_key)
|
|
113
|
+
num_to, _ = _parse_key_to_num(to_key)
|
|
114
|
+
if num_from < 0 or num_to < 0:
|
|
115
|
+
return 0
|
|
116
|
+
diff = (num_to - num_from) % 12
|
|
117
|
+
return diff if diff <= 6 else diff - 12
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_tempo_fit_critic(
|
|
121
|
+
profile: SampleProfile,
|
|
122
|
+
session_tempo: float = 120.0,
|
|
123
|
+
) -> CriticResult:
|
|
124
|
+
"""Score how well the sample's BPM fits the session tempo."""
|
|
125
|
+
if profile.bpm is None:
|
|
126
|
+
return CriticResult(
|
|
127
|
+
critic_name="tempo_fit", score=0.0,
|
|
128
|
+
recommendation="BPM unknown — estimate from onsets or verify manually",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
bpm = profile.bpm
|
|
132
|
+
# Check exact, half, double
|
|
133
|
+
ratios = [bpm / session_tempo, bpm / (session_tempo * 2), bpm / (session_tempo / 2)]
|
|
134
|
+
best_ratio = min(ratios, key=lambda r: abs(r - 1.0))
|
|
135
|
+
deviation = abs(best_ratio - 1.0)
|
|
136
|
+
|
|
137
|
+
if deviation < 0.01:
|
|
138
|
+
score, rec = 1.0, "Exact tempo match — no warping needed"
|
|
139
|
+
elif deviation < 0.02:
|
|
140
|
+
score, rec = 0.95, f"Near-exact match — minimal warping"
|
|
141
|
+
elif deviation < 0.05:
|
|
142
|
+
score, rec = 0.8, f"Within 5% — light warp preserves quality"
|
|
143
|
+
elif deviation < 0.10:
|
|
144
|
+
score, rec = 0.6, f"Within 10% — moderate warp, choose mode carefully"
|
|
145
|
+
elif deviation < 0.15:
|
|
146
|
+
score, rec = 0.4, f"Within 15% — significant warp, use Texture mode for ambient"
|
|
147
|
+
else:
|
|
148
|
+
score, rec = 0.2, f"Extreme tempo mismatch — use as texture, not rhythmically"
|
|
149
|
+
|
|
150
|
+
# Check if half/double time is the best match
|
|
151
|
+
if abs(bpm / session_tempo - 0.5) < 0.05:
|
|
152
|
+
score = max(score, 0.9)
|
|
153
|
+
rec = "Half-time match — set warp accordingly"
|
|
154
|
+
elif abs(bpm / session_tempo - 2.0) < 0.1:
|
|
155
|
+
score = max(score, 0.9)
|
|
156
|
+
rec = "Double-time match — set warp accordingly"
|
|
157
|
+
|
|
158
|
+
return CriticResult(critic_name="tempo_fit", score=score, recommendation=rec)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def run_frequency_fit_critic(
|
|
162
|
+
profile: SampleProfile,
|
|
163
|
+
mix_snapshot: Optional[dict] = None,
|
|
164
|
+
) -> CriticResult:
|
|
165
|
+
"""Score frequency fit against existing mix.
|
|
166
|
+
|
|
167
|
+
Without mix_snapshot (no M4L bridge), returns neutral 0.5.
|
|
168
|
+
"""
|
|
169
|
+
if mix_snapshot is None or not mix_snapshot:
|
|
170
|
+
return CriticResult(
|
|
171
|
+
critic_name="frequency_fit", score=0.5,
|
|
172
|
+
recommendation="No spectral data — verify frequency fit by ear",
|
|
173
|
+
adjustments=[{"note": "stub — spectral overlap analysis not yet implemented"}],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Basic frequency overlap check using mix_snapshot track data
|
|
177
|
+
# mix_snapshot expected shape: {"tracks": [{"name": ..., "peak_frequency": ...}]}
|
|
178
|
+
tracks = mix_snapshot.get("tracks", [])
|
|
179
|
+
if not tracks:
|
|
180
|
+
return CriticResult(
|
|
181
|
+
critic_name="frequency_fit", score=0.5,
|
|
182
|
+
recommendation="Mix snapshot has no track data",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Use sample's frequency_center to check for crowding
|
|
186
|
+
sample_center = profile.frequency_center
|
|
187
|
+
if sample_center <= 0:
|
|
188
|
+
return CriticResult(
|
|
189
|
+
critic_name="frequency_fit", score=0.5,
|
|
190
|
+
recommendation="Sample has no spectral data — verify by ear",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Count tracks with energy near the sample's center frequency
|
|
194
|
+
crowding = 0
|
|
195
|
+
for track in tracks:
|
|
196
|
+
track_peak = track.get("peak_frequency", 0)
|
|
197
|
+
if track_peak > 0 and abs(track_peak - sample_center) < sample_center * 0.3:
|
|
198
|
+
crowding += 1
|
|
199
|
+
|
|
200
|
+
if crowding == 0:
|
|
201
|
+
score, rec = 1.0, "Fills an empty frequency range — no overlap"
|
|
202
|
+
elif crowding == 1:
|
|
203
|
+
score, rec = 0.7, "Some frequency overlap — EQ carving recommended"
|
|
204
|
+
elif crowding == 2:
|
|
205
|
+
score, rec = 0.4, "Significant masking risk — aggressive filtering needed"
|
|
206
|
+
else:
|
|
207
|
+
score, rec = 0.2, "Heavy frequency crowding — use as texture only"
|
|
208
|
+
|
|
209
|
+
return CriticResult(critic_name="frequency_fit", score=score, recommendation=rec)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def run_role_fit_critic(
|
|
213
|
+
profile: SampleProfile,
|
|
214
|
+
existing_roles: Optional[list[str]] = None,
|
|
215
|
+
) -> CriticResult:
|
|
216
|
+
"""Score whether this sample fills a missing role in the song."""
|
|
217
|
+
if existing_roles is None:
|
|
218
|
+
return CriticResult(
|
|
219
|
+
critic_name="role_fit", score=0.5,
|
|
220
|
+
recommendation="No role data available",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Map material types to roles they fill
|
|
224
|
+
role_map = {
|
|
225
|
+
"vocal": ["vocal", "voice", "melody"],
|
|
226
|
+
"drum_loop": ["drums", "percussion", "rhythm", "beat"],
|
|
227
|
+
"one_shot": ["drums", "percussion", "hit"],
|
|
228
|
+
"instrument_loop": ["synth", "keys", "guitar", "melody"],
|
|
229
|
+
"texture": ["texture", "pad", "ambient", "atmosphere"],
|
|
230
|
+
"foley": ["texture", "foley", "sfx"],
|
|
231
|
+
"fx": ["fx", "transition", "riser"],
|
|
232
|
+
"full_mix": [],
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
sample_roles = role_map.get(profile.material_type, [])
|
|
236
|
+
existing_lower = [r.lower() for r in existing_roles]
|
|
237
|
+
|
|
238
|
+
# Check for overlap
|
|
239
|
+
overlap = sum(1 for r in sample_roles if any(r in e for e in existing_lower))
|
|
240
|
+
|
|
241
|
+
if overlap == 0 and sample_roles:
|
|
242
|
+
score = 1.0
|
|
243
|
+
rec = f"Fills missing role — no existing {profile.material_type} in track"
|
|
244
|
+
elif overlap == 0:
|
|
245
|
+
score = 0.5
|
|
246
|
+
rec = "Material type unclear for role analysis"
|
|
247
|
+
elif sample_roles and overlap >= len(sample_roles) / 2:
|
|
248
|
+
score = 0.3
|
|
249
|
+
rec = f"Redundant — already have {', '.join(existing_lower[:3])}. Use as texture instead"
|
|
250
|
+
elif overlap < len(sample_roles):
|
|
251
|
+
score = 0.7
|
|
252
|
+
rec = "Some role overlap — complements existing elements"
|
|
253
|
+
else:
|
|
254
|
+
score = 0.3
|
|
255
|
+
rec = f"Redundant — already have {', '.join(existing_lower[:3])}. Use as texture instead"
|
|
256
|
+
|
|
257
|
+
return CriticResult(critic_name="role_fit", score=score, recommendation=rec)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run_vibe_fit_critic(
|
|
261
|
+
profile: SampleProfile,
|
|
262
|
+
taste_graph: object = None,
|
|
263
|
+
) -> CriticResult:
|
|
264
|
+
"""Score vibe fit using TasteGraph if available.
|
|
265
|
+
|
|
266
|
+
Uses brightness + transient_density as an energy proxy and compares
|
|
267
|
+
against taste_graph.novelty_band:
|
|
268
|
+
high novelty_band → user likes intense/novel → high energy fits better
|
|
269
|
+
low novelty_band → user likes subtle/familiar → low energy fits better
|
|
270
|
+
"""
|
|
271
|
+
if taste_graph is None or not hasattr(taste_graph, "evidence_count"):
|
|
272
|
+
return CriticResult(
|
|
273
|
+
critic_name="vibe_fit", score=0.5,
|
|
274
|
+
recommendation="No taste data — neutral score",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if taste_graph.evidence_count == 0:
|
|
278
|
+
return CriticResult(
|
|
279
|
+
critic_name="vibe_fit", score=0.5,
|
|
280
|
+
recommendation="No taste evidence yet — neutral score",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Compute energy proxy from sample characteristics (0.0 - 1.0)
|
|
284
|
+
# brightness and transient_density are both 0.0-1.0 range
|
|
285
|
+
energy = (profile.brightness + profile.transient_density) / 2.0
|
|
286
|
+
energy = max(0.0, min(1.0, energy))
|
|
287
|
+
|
|
288
|
+
# Compare against novelty_band as taste proxy
|
|
289
|
+
novelty_band = getattr(taste_graph, "novelty_band", 0.5)
|
|
290
|
+
novelty_band = max(0.0, min(1.0, novelty_band))
|
|
291
|
+
|
|
292
|
+
# Score: how well sample energy aligns with user's novelty preference
|
|
293
|
+
# Perfect alignment = 1.0, maximum mismatch = 0.2
|
|
294
|
+
distance = abs(energy - novelty_band)
|
|
295
|
+
score = max(0.2, 1.0 - distance)
|
|
296
|
+
|
|
297
|
+
if score >= 0.8:
|
|
298
|
+
rec = "Vibe aligns well with taste profile"
|
|
299
|
+
elif score >= 0.6:
|
|
300
|
+
rec = "Reasonable vibe match — minor energy difference"
|
|
301
|
+
elif score >= 0.4:
|
|
302
|
+
rec = "Vibe mismatch — sample energy differs from taste preference"
|
|
303
|
+
else:
|
|
304
|
+
rec = "Strong vibe clash — consider processing to shift energy"
|
|
305
|
+
|
|
306
|
+
return CriticResult(critic_name="vibe_fit", score=score, recommendation=rec)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def run_intent_fit_critic(
|
|
310
|
+
profile: SampleProfile,
|
|
311
|
+
intent: Optional[SampleIntent] = None,
|
|
312
|
+
) -> CriticResult:
|
|
313
|
+
"""Score how well the material serves the stated intent."""
|
|
314
|
+
if intent is None:
|
|
315
|
+
return CriticResult(
|
|
316
|
+
critic_name="intent_fit", score=0.5,
|
|
317
|
+
recommendation="No intent specified",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Intent-material compatibility matrix
|
|
321
|
+
compat: dict[str, dict[str, float]] = {
|
|
322
|
+
"rhythm": {
|
|
323
|
+
"drum_loop": 1.0, "one_shot": 0.9, "vocal": 0.6,
|
|
324
|
+
"instrument_loop": 0.5, "full_mix": 0.4,
|
|
325
|
+
"texture": 0.2, "foley": 0.5, "fx": 0.3,
|
|
326
|
+
},
|
|
327
|
+
"texture": {
|
|
328
|
+
"texture": 1.0, "foley": 0.8, "vocal": 0.6,
|
|
329
|
+
"drum_loop": 0.5, "instrument_loop": 0.6,
|
|
330
|
+
"one_shot": 0.4, "fx": 0.7, "full_mix": 0.5,
|
|
331
|
+
},
|
|
332
|
+
"layer": {
|
|
333
|
+
"instrument_loop": 1.0, "vocal": 0.8, "texture": 0.7,
|
|
334
|
+
"drum_loop": 0.6, "one_shot": 0.3, "foley": 0.4,
|
|
335
|
+
},
|
|
336
|
+
"melody": {
|
|
337
|
+
"instrument_loop": 1.0, "vocal": 0.9, "one_shot": 0.5,
|
|
338
|
+
"texture": 0.3, "drum_loop": 0.2,
|
|
339
|
+
},
|
|
340
|
+
"vocal": {
|
|
341
|
+
"vocal": 1.0, "instrument_loop": 0.3, "texture": 0.2,
|
|
342
|
+
},
|
|
343
|
+
"atmosphere": {
|
|
344
|
+
"texture": 1.0, "foley": 0.9, "vocal": 0.5,
|
|
345
|
+
"fx": 0.8, "full_mix": 0.4,
|
|
346
|
+
},
|
|
347
|
+
"transform": {
|
|
348
|
+
# Everything is transformable — alchemist territory
|
|
349
|
+
"vocal": 0.9, "drum_loop": 0.9, "instrument_loop": 0.9,
|
|
350
|
+
"one_shot": 0.8, "texture": 0.8, "foley": 0.8,
|
|
351
|
+
"fx": 0.7, "full_mix": 0.7,
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
intent_scores = compat.get(intent.intent_type, {})
|
|
356
|
+
score = intent_scores.get(profile.material_type, 0.4)
|
|
357
|
+
|
|
358
|
+
if score >= 0.8:
|
|
359
|
+
rec = f"Natural fit for {intent.intent_type}"
|
|
360
|
+
elif score >= 0.6:
|
|
361
|
+
rec = f"Works for {intent.intent_type} with some processing"
|
|
362
|
+
elif score >= 0.4:
|
|
363
|
+
rec = f"Creative use required for {intent.intent_type} — consider alchemist approach"
|
|
364
|
+
else:
|
|
365
|
+
rec = f"Unusual match — would need heavy transformation"
|
|
366
|
+
|
|
367
|
+
return CriticResult(critic_name="intent_fit", score=score, recommendation=rec)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ── Composite Runner ────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def run_all_sample_critics(
|
|
374
|
+
profile: SampleProfile,
|
|
375
|
+
intent: Optional[SampleIntent] = None,
|
|
376
|
+
song_key: Optional[str] = None,
|
|
377
|
+
session_tempo: float = 120.0,
|
|
378
|
+
existing_roles: Optional[list[str]] = None,
|
|
379
|
+
mix_snapshot: Optional[dict] = None,
|
|
380
|
+
taste_graph: object = None,
|
|
381
|
+
) -> dict[str, CriticResult]:
|
|
382
|
+
"""Run the full 6-critic battery. Returns dict keyed by critic name."""
|
|
383
|
+
return {
|
|
384
|
+
"key_fit": run_key_fit_critic(profile, song_key),
|
|
385
|
+
"tempo_fit": run_tempo_fit_critic(profile, session_tempo),
|
|
386
|
+
"frequency_fit": run_frequency_fit_critic(profile, mix_snapshot),
|
|
387
|
+
"role_fit": run_role_fit_critic(profile, existing_roles),
|
|
388
|
+
"vibe_fit": run_vibe_fit_critic(profile, taste_graph),
|
|
389
|
+
"intent_fit": run_intent_fit_critic(profile, intent),
|
|
390
|
+
}
|