File size: 10,873 Bytes
2bafd26
80b29a8
 
 
 
 
 
2bafd26
 
 
 
 
 
 
 
 
 
 
 
 
80b29a8
 
 
 
2bafd26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80b29a8
 
2bafd26
 
 
 
 
 
 
 
 
 
 
 
80b29a8
 
 
2bafd26
 
80b29a8
2bafd26
 
80b29a8
2bafd26
80b29a8
2bafd26
 
 
 
 
80b29a8
2bafd26
80b29a8
 
2bafd26
 
 
80b29a8
 
 
2bafd26
 
80b29a8
 
 
2bafd26
 
 
80b29a8
 
 
 
 
2bafd26
 
 
80b29a8
 
 
2bafd26
80b29a8
2bafd26
80b29a8
2bafd26
80b29a8
 
 
 
2bafd26
 
 
 
 
80b29a8
 
 
 
 
2bafd26
 
7e225ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2bafd26
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
"""Named mood faders → label, key, meter. Radar uses top-5 moods only."""

from __future__ import annotations

import json
from typing import Any

from presets import GENRES, MOOD_EXTRA

# All mood names available as sliders (stable order).
_ALL: list[str] = []
for _g in GENRES.values():
    for _m in _g.get("moods", []):
        if _m not in _ALL:
            _ALL.append(_m)
for _m in MOOD_EXTRA:
    if _m not in _ALL:
        _ALL.append(_m)

MOOD_NAMES: tuple[str, ...] = tuple(_ALL)

_MAJOR = ["C major", "G major", "D major", "A major", "F major", "Bb major", "Eb major"]
_MINOR = ["A minor", "E minor", "D minor", "C minor", "F minor", "G minor", "B minor"]

# Heuristics for key / meter from named moods
_MINOR_BIAS = {
    "dark",
    "melancholic",
    "mysterious",
    "aggressive",
    "brooding",
    "gritty",
    "heartbroken",
    "bittersweet",
    "introspective",
    "suspenseful",
}
_MAJOR_BIAS = {
    "uplifting",
    "euphoric",
    "playful",
    "romantic",
    "celebratory",
    "sunny",
    "hopeful",
    "confident",
    "festive",
    "joyful",
    "anthemic",
    "catchy",
}
_WALTZISH = {"intimate", "tender", "sensual", "wistful", "pastoral", "smoky"}
_COMPOUND = {"hypnotic", "groovy", "peak-time", "euphoric", "festive"}


def _blank() -> dict[str, int]:
    return {m: 0 for m in MOOD_NAMES}


def default_dims(genre: str) -> dict[str, int]:
    """Genre moods start high; shared extras low; primary genre mood strongest."""
    dims = _blank()
    g = GENRES.get(genre, {})
    primary = list(g.get("moods", []))
    for i, name in enumerate(primary):
        if name in dims:
            dims[name] = max(35, 88 - i * 12)
    # slight presence on related extras so the board isn't empty-looking
    for name in MOOD_EXTRA:
        if name in dims and dims[name] == 0:
            dims[name] = 8
    return dims


def parse_dims(raw: Any) -> dict[str, int]:
    base = _blank()
    data: dict = {}
    if isinstance(raw, dict):
        data = raw
    elif isinstance(raw, str) and raw.strip():
        try:
            data = json.loads(raw)
        except json.JSONDecodeError:
            data = {}
    for k in MOOD_NAMES:
        try:
            base[k] = max(0, min(100, int(data.get(k, base[k]))))
        except (TypeError, ValueError):
            pass
    return base


def top_moods(dims: dict[str, int], n: int = 5) -> list[tuple[str, int]]:
    ranked = sorted(dims.items(), key=lambda kv: (-kv[1], kv[0]))
    return [(k, v) for k, v in ranked if v > 0][:n] or ranked[:n]


def mood_label(dims: dict[str, int]) -> str:
    tops = top_moods(dims, 3)
    return " ".join(k for k, _ in tops) if tops else "atmospheric"


def derive_key(dims: dict[str, int], genre_keys: list[str] | None = None) -> str:
    minor_s = sum(dims.get(m, 0) for m in _MINOR_BIAS)
    major_s = sum(dims.get(m, 0) for m in _MAJOR_BIAS)
    want_major = major_s >= minor_s
    pool = genre_keys or (_MAJOR if want_major else _MINOR)
    majors = [k for k in pool if "major" in k.lower()]
    minors = [k for k in pool if "minor" in k.lower()]
    preferred = majors if want_major else minors
    if preferred:
        warm = dims.get("romantic", 0) + dims.get("warm", 0) + dims.get("sensual", 0)
        return preferred[warm % len(preferred)]
    return pool[0] if pool else ("C major" if want_major else "A minor")


def derive_meter(dims: dict[str, int]) -> str:
    if sum(dims.get(m, 0) for m in _WALTZISH) >= 90:
        return "3"
    if sum(dims.get(m, 0) for m in _COMPOUND) >= 140:
        return "6"
    if dims.get("aggressive", 0) >= 70 and dims.get("raw", 0) + dims.get("chaotic", 0) >= 40:
        return "2"
    return "4"


def resolve_mood_bundle(
    raw_dims: Any, genre: str, genre_keys: list[str] | None = None
) -> tuple[str, str, str, str]:
    dims = default_dims(genre) if not raw_dims else parse_dims(raw_dims)
    if isinstance(raw_dims, dict) and not raw_dims:
        dims = default_dims(genre)
    label = mood_label(dims)
    key = derive_key(dims, genre_keys)
    meter = derive_meter(dims)
    return json.dumps(dims), label, key, meter


# Style keyword → mood names to emphasize (soft defaults on style change)
_STYLE_MOOD_HINTS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [
    (("dark", "doom", "drill", "trap", "industrial", "black"), ("dark", "aggressive", "brooding", "mysterious", "suspenseful")),
    (("ballad", "intimate", "quiet", "lovers", "tender"), ("romantic", "intimate", "vulnerable", "heartfelt", "tender")),
    (("dance", "club", "party", "house", "techno", "trance", "edm", "peak"), ("euphoric", "energetic", "peak-time", "festive", "hypnotic")),
    (("chill", "lo-fi", "lofi", "downtempo", "study", "ambient"), ("relaxed", "dreamy", "cozy", "focus", "melancholy")),
    (("epic", "heroic", "power", "trailer", "orchestral", "fanfare"), ("epic", "triumphant", "dramatic", "cinematic", "anthemic")),
    (("punk", "thrash", "metalcore", "hard", "garage"), ("aggressive", "raw", "rebellious", "energetic", "chaotic")),
    (("jazz", "bebop", "swing", "noir", "smoky"), ("sophisticated", "smoky", "playful", "nocturnal", "swinging")),
    (("folk", "acoustic", "americana", "campfire", "pastoral"), ("wistful", "warm", "pastoral", "storytelling", "hopeful")),
    (("soul", "r&b", "funk", "gospel", "neo-soul"), ("sensual", "smooth", "empowered", "romantic", "late-night")),
    (("reggae", "dub", "ska", "dancehall", "roots"), ("sunny", "relaxed", "groovy", "spiritual", "festive")),
    (("latin", "salsa", "bachata", "cumbia", "bossa", "reggaeton"), ("festive", "sensual", "sunny", "romantic", "dancefloor")),
    (("country", "bluegrass", "outlaw"), ("heartfelt", "homey", "bittersweet", "celebratory", "road-trip")),
    (("k-pop", "idol", "city-pop"), ("catchy", "glossy", "playful", "confident", "dramatic")),
    (("baroque", "classical", "chamber", "concerto", "minimalist", "romantic orchestral"), ("elegant", "serene", "passionate", "dramatic", "solemn")),
    (("blues", "delta", "chicago blues"), ("soulful", "heartbroken", "gritty", "late-night", "resilient")),
    (("afro", "world", "balkan", "fusion", "global"), ("celebratory", "joyful", "hypnotic", "adventurous", "warm")),
]

# Style keyword → preferred instruments (merged with genre defaults)
_STYLE_INSTRUMENT_HINTS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [
    (("orchestral", "epic", "trailer", "fanfare", "cinematic", "soundtrack"), ("string orchestra", "brass section", "timpani", "choir", "orchestral hits", "french horns")),
    (("baroque", "classical", "chamber", "concerto"), ("string quartet", "grand piano", "harpsichord", "woodwind section", "cello", "solo violin", "chamber ensemble")),
    (("solo piano", "piano", "ballad"), ("grand piano", "soft piano", "piano motif", "warm electric piano")),
    (("trap", "drill", "cloud"), ("808 bass", "trap snares", "crisp hi-hats", "atmospheric pads", "vocal chops")),
    (("house", "techno", "trance", "edm", "dance", "club"), ("four-on-the-floor kick", "analog synths", "sidechain bass", "arpeggiators", "risers", "filtered pads")),
    (("lo-fi", "lofi", "chill", "study", "downtempo"), ("dusty piano samples", "soft drums", "vinyl crackle", "mellow bass", "tape hiss", "jazzy chords")),
    (("metal", "thrash", "doom", "metalcore"), ("distorted guitars", "double bass drums", "thunderous bass", "shred solos", "orchestral hits")),
    (("punk", "garage", "hard rock"), ("distorted electric guitars", "live drums", "bass guitar", "electric guitar")),
    (("jazz", "bebop", "swing"), ("upright bass", "piano", "brushed drums", "saxophone", "trumpet", "rhodes")),
    (("folk", "acoustic", "americana", "campfire"), ("acoustic guitar", "fingerpicking", "harmonica", "mandolin", "light percussion")),
    (("reggae", "dub", "ska"), ("offbeat guitar skank", "deep bass", "rimshot drums", "organ bubble", "echo delays")),
    (("latin", "salsa", "bachata", "cumbia", "bossa"), ("syncopated percussion", "nylon guitar", "congas", "horn section", "bass groove")),
    (("country", "bluegrass"), ("acoustic guitar", "pedal steel", "fiddle", "banjo", "twangy electric guitar")),
    (("blues",), ("blues guitar", "harmonica", "upright piano", "shuffle drums", "walking bass")),
    (("soul", "r&b", "gospel", "funk"), ("warm electric piano", "smooth bass", "brushed drums", "horn section", "gospel organ", "rhodes")),
    (("synth", "synthwave", "electropop", "synth-pop"), ("bright synths", "analog synths", "arpeggiators", "punchy drums", "bass guitar")),
    (("ambient", "minimalist"), ("atmospheric pads", "filtered pads", "field recordings", "soft piano", "string orchestra")),
]


def style_mood_dims(genre: str, style: str) -> dict[str, int]:
    """Genre mood defaults, nudged to fit the selected style."""
    dims = default_dims(genre)
    s = (style or "").lower()
    if not s:
        return dims
    matched = False
    matched_moods: set[str] = set()
    for keys, moods in _STYLE_MOOD_HINTS:
        if any(k in s for k in keys):
            matched = True
            matched_moods.update(moods)
            for m in moods:
                if m in dims:
                    dims[m] = min(100, max(dims[m] + 28, 62))
            break
    if matched:
        # Soften non-boosted genre primaries slightly so the style read is clearer
        for m in list(GENRES.get(genre, {}).get("moods", [])):
            if m in dims and m not in matched_moods and dims[m] > 40:
                dims[m] = max(28, dims[m] - 12)
    return dims


def style_instruments(genre: str, style: str) -> list[str]:
    """Preselect instruments for genre + style (always a subset of INSTRUMENTS_ALL)."""
    from presets import INSTRUMENTS_ALL

    g = GENRES.get(genre, GENRES.get(next(iter(GENRES))))
    base = [i for i in (g or {}).get("instruments", []) if i in INSTRUMENTS_ALL]
    s = (style or "").lower()
    extra: list[str] = []
    for keys, insts in _STYLE_INSTRUMENT_HINTS:
        if any(k in s for k in keys):
            for i in insts:
                if i and i in INSTRUMENTS_ALL:
                    extra.append(i)
            break
    out: list[str] = []
    for i in extra + base:
        if i not in out:
            out.append(i)
    if not out:
        out = list(INSTRUMENTS_ALL[:4])
    return out[:5]


def resolve_style_bundle(genre: str, style: str) -> tuple[str, str, str, str, list[str], int]:
    """Mood JSON, label, key, meter, instruments, bpm for a genre+style pair."""
    from presets import suggest_style_bpm

    g = GENRES.get(genre) or GENRES[next(iter(GENRES))]
    dims = style_mood_dims(genre, style)
    dims_s, label, key, meter = resolve_mood_bundle(dims, genre, g.get("keys"))
    instruments = style_instruments(genre, style)
    bpm = suggest_style_bpm(genre, style)
    return dims_s, label, key, meter, instruments, bpm


def mood_names_json() -> str:
    return json.dumps(list(MOOD_NAMES))