Spaces:
Sleeping
Sleeping
| # 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() | |