File size: 7,269 Bytes
6870c62
1ba66ff
6870c62
 
1ba66ff
6870c62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ba66ff
 
 
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
# app.py
import gradio as gr
from typing import List, Tuple
import re

# ----- Pitch utilities -----
SHARP_NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]
FLAT_NAMES  = ["C","Db","D","Eb","E","F","Gb","G","Ab","A","Bb","B"]

NAME_TO_PC = {
    # naturals
    "C":0, "D":2, "E":4, "F":5, "G":7, "A":9, "B":11,
    # sharps
    "C#":1, "D#":3, "F#":6, "G#":8, "A#":10,
    # flats
    "Db":1, "Eb":3, "Gb":6, "Ab":8, "Bb":10,
    # unicode ♯/♭
    "C♯":1, "D♯":3, "F♯":6, "G♯":8, "A♯":10,
    "D♭":1, "E♭":3, "G♭":6, "A♭":8, "B♭":10,
}

# Common chord templates: set of intervals from root (0 always present)
CHORD_TEMPLATES = {
    (0,4,7): ("major triad", ""),
    (0,3,7): ("minor triad", "m"),
    (0,3,6): ("diminished triad", "dim"),
    (0,4,8): ("augmented triad", "+"),
    (0,2,7): ("sus2", "sus2"),
    (0,5,7): ("sus4", "sus4"),
    (0,4,7,10): ("dominant 7th", "7"),
    (0,4,7,11): ("major 7th", "maj7"),
    (0,3,7,10): ("minor 7th", "m7"),
    (0,3,6,10): ("half-diminished (m7♭5)", "m7b5"),
    (0,3,6,9): ("diminished 7th", "dim7"),
    (0,3,7,11): ("minor major 7th", "m(maj7)"),
    (0,4,7,9): ("major 6th", "6"),
    (0,3,7,9): ("minor 6th", "m6"),
}

ADD_TONES = {
    2: ("add9", "add9"),  # treat 2 as add9
    9: ("add9", "add9"),
    11: ("add11", "add11"),
    6: ("add13", "add13"),
}

SUS_OVERLAPS = {(0,2,7), (0,5,7)}

NOTE_TOKEN_RE = re.compile(r"[A-Ga-g](?:#|b|♯|♭)?")


def pc_name(pc: int, prefer_flats: bool) -> str:
    return (FLAT_NAMES if prefer_flats else SHARP_NAMES)[pc % 12]


def parse_notes(user_text: str) -> Tuple[List[int], bool, List[str]]:
    """Return (pitch_classes, prefer_flats, original_tokens)."""
    tokens = NOTE_TOKEN_RE.findall(user_text)
    tokens = [t.upper().replace("♯", "#").replace("♭", "b") for t in tokens]
    prefer_flats = any("B" in t and len(t)>1 for t in tokens) or any("b" in t for t in tokens)
    pcs = []
    for t in tokens:
        if t in NAME_TO_PC:
            pcs.append(NAME_TO_PC[t])
    # dedupe while preserving order
    seen = set()
    pcs_unique = []
    for p in pcs:
        if p not in seen:
            pcs_unique.append(p)
            seen.add(p)
    return pcs_unique, prefer_flats, tokens


def intervals_from_root(pcs: List[int], root: int) -> Tuple[int,...]:
    ints = sorted(((p - root) % 12) for p in pcs)
    if 0 not in ints:
        ints = (0,) + tuple(i for i in ints)
    return tuple(sorted(set(ints)))


def describe_chord(pcs: List[int], prefer_flats: bool) -> str:
    if len(pcs) < 3:
        return "Please provide at least 3 distinct note names (e.g., C E G or C, Eb, G)."

    matches = []
    for root in pcs:  # try each included pitch as potential root
        base_ints = intervals_from_root(pcs, root)
        # Try exact template match first (triads/sevenths/6ths/etc.)
        if base_ints in CHORD_TEMPLATES:
            qual_name, suffix = CHORD_TEMPLATES[base_ints]
            matches.append((root, qual_name, suffix, []))
            continue
        # Try triad with added tones (add9/add11/add13)
        # Identify a triad subset and extra tones
        for triad in [(0,4,7), (0,3,7), (0,3,6), (0,4,8)]:
            triad_set = set(triad)
            if triad_set.issubset(set(base_ints)):
                extras = sorted(set(base_ints) - triad_set)
                add_suffixes = []
                for e in extras:
                    if e in ADD_TONES:
                        add_suffixes.append(ADD_TONES[e][1])
                if add_suffixes:
                    qual_name, suffix = CHORD_TEMPLATES.get(triad, ("triad",""))
                    matches.append((root, f"{qual_name} with {'/'.join(add_suffixes)}", suffix + ("" if not add_suffixes else ("("+" ".join(add_suffixes)+")")), extras))
        # sus chords (if match) possibly with added tones
        for sus in [(0,2,7), (0,5,7)]:
            if set(sus).issubset(set(base_ints)):
                extras = sorted(set(base_ints) - set(sus))
                add_suffixes = []
                for e in extras:
                    if e in ADD_TONES:
                        add_suffixes.append(ADD_TONES[e][1])
                qual_name, suffix = CHORD_TEMPLATES[sus]
                matches.append((root, qual_name + (" with "+"/".join(add_suffixes) if add_suffixes else ""), suffix + ("" if not add_suffixes else ("("+" ".join(add_suffixes)+")")), extras))

    if not matches:
        # fallback: show interval set relative to lowest note as a hint
        root_guess = min(pcs)
        ints = intervals_from_root(pcs, root_guess)
        return (
            "I couldn't confidently name that chord. Interval set from {}: {}.\n"
            "Try removing extensions/duplicates or check note spelling (sharps vs flats)."
        ).format(pc_name(root_guess, prefer_flats), ",".join(str(i) for i in ints))

    # Rank matches: prefer templates with no ambiguous extras, prefer 7th/6th over triad with adds, then by root being lowest provided note
    def rank(m):
        root, qual_name, suffix, extras = m
        score = 0
        if "7" in suffix or "6" in suffix:
            score += 2
        if not extras:
            score += 1
        if root == min(pcs):
            score += 0.5
        return -score

    matches.sort(key=rank)

    # Build a readable response listing top 1-3 candidates
    top = matches[:3]
    lines = []
    for i,(root, qual_name, suffix, extras) in enumerate(top, start=1):
        name = pc_name(root, prefer_flats)
        symbol = name + (suffix if suffix else "")
        spelled = ", ".join(pc_name(p, prefer_flats) for p in sorted(set(pcs)))
        lines.append(f"{i}. {symbol}{qual_name} (notes: {spelled})")

    # Inversion hint (best-effort): if lowest note isn't the chosen root, suggest slash chord
    chosen_root = top[0][0]
    lowest = min(pcs)
    if lowest != chosen_root:
        lines[0] += f"  — likely {pc_name(chosen_root, prefer_flats)}/{pc_name(lowest, prefer_flats)}"

    return "\n".join(lines)


def answer(message: str, history: List[Tuple[str,str]]):
    pcs, prefer_flats, tokens = parse_notes(message)
    if not tokens:
        return (
            "Tell me 3+ notes (e.g., 'C E G' or 'Db, F, Ab, C'). "
            "I support #/b and unicode ♯/♭."
        )
    return describe_chord(pcs, prefer_flats)


def example_inputs():
    return [
        "C E G",
        "D F# A C",
        "C Eb G Bb",
        "F A C D",
        "G Bb D F",
        "Db F Ab C",
        "C D G",
        "A C E G#",
        "E G# B D",
        "C Eb Gb A"
    ]

with gr.Blocks(theme=gr.themes.Soft()) as demo:
    gr.Markdown("""
    # 🎵 Note → Chord Chatbot
    Type 3 or more note names and I'll guess the chord (triads, 7ths, 6ths, sus, and common add tones).
    - Accepted formats: `C E G`, `Db, F, Ab, C`, `G-B-D-F`, etc.
    - Sharps/flats: `#`, `b`, or unicode `♯` `♭`.
    """)
    chat = gr.ChatInterface(
        fn=answer,
        examples=[[e] for e in example_inputs()],
        title="Chord Identifier",
        retry_btn=None,
        undo_btn="Delete last turn",
        clear_btn="Clear",
        textbox=gr.Textbox(placeholder="e.g., C E G or Db, F, Ab", label="Your notes"),
    )

if __name__ == "__main__":
    demo.launch()