# Project export: SongSense

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Can't remember a song's name? Hum a few notes, drop a half-remembered lyric, or just describe the vibe — SongSense finds it using AI, even when you have nothing concrete to go on.
- Devpost: https://devpost.com/software/songsense
- GitHub: https://github.com/ianliu1119/SongSense
- Team: 1 GitHub contributor(s) — Ian Liu (4 commits)

## Devpost submission (written by the team)

### Inspiration

Apps like Shazam are great at one thing: identifying a song you're currently hearing. But that's not how most "what's that song" moments actually happen. More often, you're left with a fragment — a half-remembered lyric, a mood, a genre, the way a melody felt — and no audio to feed into a fingerprinting algorithm. There was no good tool for that gap: searching for a song by description rather than by recording. SongSense was built to fill it.

### What it does

SongSense lets users search for a song using any combination of four loose, fragmentary inputs — a hummed/recorded melody, a genre, a lyric snippet, or free-text mood/description — and returns the top 5 closest matches. None of the inputs need to be exact or complete; the whole point is supporting the "I don't really remember, but it felt like..." search.

### How we built it

Frontend: React Native (Expo SDK 54), with three tabs (Search, History, Saved) built using React Navigation, running cross-platform on iOS, Android, and web. Backend: FastAPI serving a local REST API on port 8000, backed by SQLite (a songs catalog, search_history, and saved_songs tables). AI matching: every song is flattened into one descriptive string (title, artist, genre, mood keywords, lyric snippet, description) and embedded with all-MiniLM-L6-v2 (sentence-transformers) at startup. A user's query is embedded the same way, and ranked against all song vectors by cosine similarity: $$\text{sim}(q, s) = \frac{q \cdot s}{|q| |s|}$$ Since every embedding is pre-normalized to unit length, this reduces to a single dot product, so the whole catalog can be ranked with one matrix-vector multiply (song_matrix @ q) instead of a per-song loop. Audio: 30-second previews are pulled from Wikimedia Commons (public domain, for classical pieces) and the iTunes Search API (for modern songs), played inline via expo-av.

### Challenges we ran into

The hardest part wasn't the plumbing — it was getting the matching itself to feel right. A few specific issues: Field weighting. Concatenating title, artist, genre, mood, lyric, and description into one string means a strong match on, say, mood keywords can get diluted by irrelevant noise elsewhere in the string. Tuning what goes into the descriptive text (and how it's phrased) mattered more than expected. Score interpretability. Cosine similarity scores don't have an obvious "good match" threshold — a 0.45 might be a strong match for one query and a weak one for another, so deciding how to rank/display confidence took iteration. Designing for a second signal in advance. Knowing that hum-based melody matching was coming in Phase 2, the matching engine had to be architected to blend two independent similarity scores (text + audio) via a weighted sum, without knowing yet what scale or distribution the audio scores would have. That meant building the normalization and blending logic defensively, ahead of having real data to test it against.

### What we learned

*How to turn a fuzzy, human "vibe" query into something a vector space can actually rank. That embedding-based search needs content design, not just model selection — what you embed matters as much as which model embeds it. How to architect a multi-signal ranking system (text now, audio later) so a new scoring signal can be dropped in without reworking the pipeline.

### What's next

Implementing real melody/hum matching to replace the score_hum() stub, tuning the text/hum blend weights once both signals are live, migrating the databases to more formal database instead of sqlite, and expanding the song database.

## README (from the GitHub repository)

# SongSense

A Shazam-style mobile app that finds songs from **a hummed tune, lyrics, genre, or freeform keywords** — matched against a 334-song catalog using AI semantic search.

- **Frontend:** Expo / React Native — runs on iOS, Android, and web
- **Backend:** FastAPI + SQLite
- **Matching:** `sentence-transformers` (`all-MiniLM-L6-v2`) text embeddings with cosine similarity
- **Search labels:** auto-generated using semantic vibe/era matching (e.g. "Romantic Jazz", "Energetic 80s Pop")
- **Audio previews:** 30-second clips via iTunes (modern) and Wikimedia Commons (classical)
- **Album artwork:** fetched from iTunes / Deezer APIs

---

## Project layout

```
backend/
  main.py            FastAPI app & endpoints
  db.py              SQLite schema, seeding, history/saved persistence
  matcher.py         embedding-based ranking + search label generation
  songs_seed.py      original 20-song seed catalog
  expand_db.py       script that expanded catalog to 334 songs + iTunes previews
  fetch_artwork.py   script that backfilled album artwork URLs
  requirements.txt
  songfinder.db      SQLite database (gitignored)

frontend/
  App.js             tab navigator (Search · History · Saved)
  screens/           SearchScreen, HistoryScreen, SavedScreen
  components/        ResultRow (artwork + play overlay + bookmark) · SavedCard
  lib/
    api.js           backend HTTP client
    useAudioPlayer.js  shared audio playback hook (expo-av)
  app.json           Expo config (iOS · Android · Web)
  package.json
```

---

## Song catalog

334 songs across pop, rock, hip-hop, R&B, electronic, jazz, classical, country, Latin, K-pop, and metal. Previews come from two sources:

| Type | Preview source | Artwork source |
|------|---------------|----------------|
| Classical (public domain) | Wikimedia Commons MP3 transcodes | iTunes / Deezer |
| Modern (copyrighted) | iTunes 30-second preview clips | iTunes / Deezer |

---

## Running the backend

```bash
cd backend
/opt/anaconda3/bin/python3 -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
```

First launch downloads the embedding model (~80 MB), runs the DB migration, and pre-computes embeddings for all 334 songs. API available at `http://localhost:8000` — interactive docs at `http://localhost:8000/docs`.

## Running the frontend

```bash
cd frontend
npx expo start
```

| Key | Action |
|-----|--------|
| `i` | Open in iOS Simulator |
| `a` | Open in Android Emulator |
| `w` | Open in browser |
| Scan QR | Open in Expo Go on your phone |

> **Physical device:** `lib/api.js` sets `BASE_URL` to your Mac's LAN IP. Update it if your IP changes, and make sure both devices are on the same Wi-Fi.

---

## How search works

1. Each song is converted to a text string: `title · artist · genres · mood keywords · lyric snippet · description`
2. All strings are embedded with `all-MiniLM-L6-v2` at startup and stored as a normalized matrix
3. The user's query (genre + lyric + extra) is embedded the same way
4. Cosine similarity is computed against all songs; top 5 are returned
5. The search history label is generated by matching the query against vibe and era descriptor embeddings (e.g. "melancholic", "80s") and combining with the genre

---

## API endpoints

| Method | Path | Purpose |
|--------|------|---------|
| POST | `/search` | Run a search (multipart: genre, lyric, extra, optional audio), store in history, return top 5 |
| GET | `/history` | List past searches |
| GET | `/history/{id}` | Re-fetch a past search's top 5 results |
| GET | `/saved` | List saved songs |
| POST | `/saved/{id}` | Bookmark a song |
| DELETE | `/saved/{id}` | Remove a bookmark |
| GET | `/songs` | Full catalog (debug) |

---

## Phase 2 — query by humming

The microphone input is wired up but melody matching is stubbed. Plan:

1. **Reference melodies** — add a `melody_contour` (normalized pitch sequence) to each song, extracted from MIDI or hand-annotated
2. **Hum → contour** — extract pitch curve from uploaded audio with `librosa` or CREPE, normalize for key/tempo
3. **Compare** — use DTW (dynamic time warping) to score hum against each contour
4. **Blend** — implement `score_hum()` in `matcher.py` and raise `w_hum` in the weighted blend


## Detected evidence (automated analysis)

Indexed codebase: 16 recognized source files, 136 KB.
- FastAPI (technology) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code

## Codebase structure (from repository index)

### Files (21 of 21)

```
backend/db.py
backend/expand_db.py
backend/fetch_artwork.py
backend/main.py
backend/matcher.py
backend/requirements.txt
backend/songfinder.db
backend/songs_seed.py
frontend/.expo/devices.json
frontend/.expo/README.md
frontend/App.js
frontend/app.json
frontend/components/SongComponents.js
frontend/lib/api.js
frontend/lib/theme.js
frontend/lib/useAudioPlayer.js
frontend/package.json
frontend/screens/HistoryScreen.js
frontend/screens/SavedScreen.js
frontend/screens/SearchScreen.js
README.md
```

### Dependencies

- backend/requirements.txt: fastapi@==0.115.0, numpy@==1.26.4, python-multipart@==0.0.9, sentence-transformers@==3.0.1, uvicorn[standard]@==0.30.6
- frontend/package.json: @expo/metro-runtime@~6.1.2, @expo/vector-icons@^15.1.1, @react-navigation/bottom-tabs@^6.5.0, @react-navigation/native@^6.1.0, expo@^54.0.0, expo-av@~16.0.8, expo-constants@~18.0.13, expo-status-bar@~3.0.9, react@^19.1.0, react-dom@19.1.0, react-native@^0.81.5, react-native-safe-area-context@~5.6.0, react-native-screens@~4.16.0, react-native-web@^0.21.0

### Recent commits (newest first)

- fix
- fix backend expo config
- Revamp UI and add delete search history feature
- Add album cover to search page and saved songs
- UI fix, search history name model implemented, remove duplicated songs
- extra fix
- frontend and backend
- Initial commit: SongFinder app scaffold

## Key source files (fetched from GitHub, selected and truncated for size)

### backend/requirements.txt

```
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
sentence-transformers==3.0.1
numpy==1.26.4

```

### frontend/package.json

```
{
  "name": "songfinder",
  "version": "1.0.0",
  "main": "node_modules/expo/AppEntry.js",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios"
  },
  "dependencies": {
    "@expo/metro-runtime": "~6.1.2",
    "@expo/vector-icons": "^15.1.1",
    "@react-navigation/bottom-tabs": "^6.5.0",
    "@react-navigation/native": "^6.1.0",
    "expo": "^54.0.0",
    "expo-av": "~16.0.8",
    "expo-constants": "~18.0.13",
    "expo-status-bar": "~3.0.9",
    "react": "^19.1.0",
    "react-dom": "19.1.0",
    "react-native": "^0.81.5",
    "react-native-safe-area-context": "~5.6.0",
    "react-native-screens": "~4.16.0",
    "react-native-web": "^0.21.0"
  }
}

```

### frontend/App.js

```javascript
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Ionicons } from "@expo/vector-icons";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { C } from "./lib/theme";

import SearchScreen from "./screens/SearchScreen";
import HistoryScreen from "./screens/HistoryScreen";
import SavedScreen from "./screens/SavedScreen";

const Tab = createBottomTabNavigator();

const ICONS = {
  Search:  { default: "search-outline",   active: "search" },
  History: { default: "time-outline",     active: "time" },
  Saved:   { default: "bookmark-outline", active: "bookmark" },
};

export default function App() {
  return (
    <SafeAreaProvider>
      <NavigationContainer>
        <Tab.Navigator
          screenOptions={({ route }) => ({
            headerShown: false,
            tabBarActiveTintColor: C.primary,
            tabBarInactiveTintColor: C.sub,
            tabBarStyle: {
              backgroundColor: C.card,
              borderTopColor: C.border,
              borderTopWidth: 1,
              height: 64,
              paddingBottom: 10,
              paddingTop: 8,
            },
            tabBarLabelStyle: { fontSize: 11, fontWeight: "600" },
            tabBarIcon: ({ color, focused, size }) => (
              <Ionicons
                name={focused ? ICONS[route.name].active : ICONS[route.name].default}
                size={size}
                color={color}
              />
            ),
          })}
        >
          <Tab.Screen name="Search"  component={SearchScreen} />
          <Tab.Screen name="History" component={HistoryScreen} />
          <Tab.Screen name="Saved"   component={SavedScreen} />
        </Tab.Navigator>
      </NavigationContainer>
    </SafeAreaProvider>
  );
}

```

### backend/main.py

```python
"""FastAPI backend for SongFinder.

Endpoints:
  POST /search            run a search, store it in history, return top 5
  GET  /history           list past searches (label + time)
  GET  /history/{id}      re-fetch the top 5 results from a past search
  GET  /saved             list saved songs
  POST /saved/{song_id}   bookmark a song
  DELETE /saved/{song_id} remove a bookmark
  GET  /songs             full catalog (debug / demo)

Run:  uvicorn main:app --reload --host 0.0.0.0 --port 8000
"""

import shutil
import tempfile
from pathlib import Path

from fastapi import FastAPI, Form, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware

import db
import matcher

app = FastAPI(title="SongFinder API")

# Allow the Expo app (any origin in dev) to call us.
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.on_event("startup")
def startup():
    db.init_db()
    matcher.load_model()




@app.post("/search")
async def search(
    genre: str = Form(""),
    lyric: str = Form(""),
    extra: str = Form(""),
    audio: UploadFile | None = File(None),
):
    audio_path = None
    if audio is not None:
        tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".m4a")
        with tmp as f:
            shutil.copyfileobj(audio.file, f)
        audio_path = tmp.name

    results = matcher.search(genre, lyric, extra, audio_path=audio_path, top_k=5)

    if audio_path:
        Path(audio_path).unlink(missing_ok=True)

    db.add_history(
        label=matcher.generate_label(genre, lyric, extra),
        genre=genre, lyric=lyric, extra=extra,
        had_audio=audio is not None,
        result_ids=[r["id"] for r in results],
    )
    return {"results": results}


@app.get("/history")
def history():
    return {"history": db.list_history()}


@app.get("/history/{history_id}")
def history_results(history_id: int):
    return {"results": db.get_history_results(history_id)}


@app.delete("/history/{history_id}")
def delete_history(history_id: int):
    db.delete_history(history_id)
    return {"ok": True}


@app.get("/saved")
def saved():
    return {"saved": db.list_saved()}


@app.post("/saved/{song_id}")
def save(song_id: int):
    db.save_song(song_id)
    return {"ok": True}


@app.delete("/saved/{song_id}")
def unsave(song_id: int):
    db.unsave_song(song_id)
    return {"ok": True}


@app.get("/songs")
def songs():
    return {"songs": db.all_songs()}

```

### backend/fetch_artwork.py

```python
"""Fetch album artwork URLs from iTunes for all songs and store in DB.

Run:  python3 fetch_artwork.py
"""

import json, sqlite3, time, urllib.request, urllib.parse
from pathlib import Path

DB_PATH = Path(__file__).parent / "songfinder.db"


def fetch_artwork(title, artist):
    term = urllib.parse.quote(f"{artist} {title}")
    url = f"https://api.deezer.com/search?q={term}&limit=5"
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "SongSense/1.0"})
        with urllib.request.urlopen(req, timeout=10) as r:
            data = json.loads(r.read())
        for track in data.get("data", []):
            art = track.get("album", {}).get("cover_xl")
            if art and title.lower() in track.get("title", "").lower():
                return art
        for track in data.get("data", []):
            art = track.get("album", {}).get("cover_xl")
            if art:
                return art
    except Exception as e:
        print(f"  error: {e}")
    return None


def main():
    conn = sqlite3.connect(DB_PATH)
    rows = conn.execute("SELECT id, title, artist, artwork_url FROM songs ORDER BY id").fetchall()
    missing = [(r[0], r[1], r[2]) for r in rows if not r[3]]
    print(f"{len(missing)} songs need artwork (out of {len(rows)} total)\n")

    for song_id, title, artist in missing:
        print(f"[{song_id}] {title} – {artist} ...", end=" ", flush=True)
        url = fetch_artwork(title, artist)
        print("✓" if url else "✗")
        if url:
            conn.execute("UPDATE songs SET artwork_url = ? WHERE id = ?", (url, song_id))
            conn.commit()
        time.sleep(0.3)

    total = conn.execute("SELECT COUNT(*) FROM songs WHERE artwork_url IS NOT NULL").fetchone()[0]
    print(f"\nDone! {total}/{len(rows)} songs have artwork.")
    conn.close()


if __name__ == "__main__":
    main()

```

### backend/matcher.py

```python
"""Matching engine.

Phase 1: text matching via sentence-transformers.

Each song is turned into one descriptive string (title, artist, genre, moods,
lyric snippet, description) and embedded once at startup. A query (genre + lyric
+ extra info, concatenated) is embedded the same way and ranked by cosine
similarity.

Phase 2 hook: `score_hum()` is stubbed. When melody matching is built, blend its
score into `final_score` with a weight.
"""

import numpy as np
from sentence_transformers import SentenceTransformer

import db

_MODEL_NAME = "all-MiniLM-L6-v2"   # small, fast, free, ~80MB
_model = None
_song_ids = []
_song_matrix = None   # (n_songs, dim) normalized embeddings

# ── Label generation descriptors ──────────────────────────────────────────────
_VIBES = [
    "upbeat", "melancholic", "romantic", "dark", "energetic", "chill",
    "nostalgic", "powerful", "emotional", "calm", "aggressive", "dreamy",
    "soulful", "funky", "epic", "raw", "playful", "intense", "gentle",
    "mysterious", "groovy", "atmospheric", "haunting", "joyful", "bittersweet",
    "late-night", "heartbreak", "feel-good", "rebellious", "spiritual",
]
_ERAS = [
    "classic", "vintage", "80s", "90s", "2000s", "2010s", "modern",
    "old-school", "retro", "contemporary",
]
_vibe_embeddings = None
_era_embeddings = None


def _song_to_text(s):
    parts = [
        s["title"],
        s["artist"],
        " ".join(s["genre"]),
        " ".join(s["mood_keywords"]),
        s["lyric_snippet"] or "",
        s["description"] or "",
    ]
    return ". ".join(p for p in parts if p)


def _normalize(mat):
    norms = np.linalg.norm(mat, axis=1, keepdims=True)
    norms[norms == 0] = 1.0
    return mat / norms


def load_model():
    """Load the model and precompute song embeddings. Call once at startup."""
    global _model, _song_ids, _song_matrix, _vibe_embeddings, _era_embeddings
    _model = SentenceTransformer(_MODEL_NAME)
    songs = db.all_songs()
    _song_ids = [s["id"] for s in songs]
    texts = [_song_to_text(s) for s in songs]
    emb = _model.encode(texts, convert_to_numpy=True)
    _song_matrix = _normalize(emb)
    _vibe_embeddings = _normalize(_model.encode(_VIBES, convert_to_numpy=True))
    _era_embeddings = _normalize(_model.encode(_ERAS, convert_to_numpy=True))


def _build_query_text(genre, lyric, extra):
    parts = [genre or "", lyric or "", extra or ""]
    return ". ".join(p.strip() for p in parts if p and p.strip())


def score_text(genre, lyric, extra, top_k=5):
    """Return [(song_id, score)] for the top_k best text matches."""
    query = _build_query_text(genre, lyric, extra)
    if not query:
        return []
    q = _model.encode([query], convert_to_numpy=True)
    q = _normalize(q)[0]
    sims = _song_matrix @ q                      # cosine, since both normalized
    order = np.argsort(-sims)[:top_k]
    return [(_song_ids[i], float(sims[i])) for i in order]


def generate_label(genre, lyric, extra):
    """Generate a descriptive search label using semantic similarity."""
    query = _build_query_text(genre, lyric, extra)
    if not query:
        return "Hum search"

    q = _normalize(_model.encode([query], convert_to_numpy=True))[0]

    # Top vibe descriptor
    top_vibe = _VIBES[int(np.argmax(_vibe_embeddings @ q))]

    # Top era — only include if it scores above a confidence threshold
    era_sims = _era_embeddings @ q
    best_era_idx = int(np.argmax(era_sims))
    top_era = _ERAS[best_era_idx] if era_sims[best_era_idx] > 0.35 else None

    # Build: "{vibe} [{era}] [{genre}]"
    parts = [top_vibe]
    if top_era:
        parts.append(top_era)
    if genre and genre.strip():
        g = genre.strip().split(",")[0].strip()
        parts.append(g)

    label = " ".join(parts)
    label = " ".join(w if w[0].isdigit() else w.capitalize() for w in label.split())
    return label[:40]


def score_hum(audio_path, top_k=5):
    """Phase 2 stub. Returns [] so text-only ranking is used for now."""
    return []


def search(genre, lyric, extra, audio_path=None, top_k=5):
    """Combine available signals and return ranked song dicts with scores."""
    text_scores = dict(score_text(genre, lyric, extra, top_k=len(_song_ids)))
    hum_scores = dict(score_hum(audio_path, top_k=len(_song_ids))) if audio_path else {}

    # Weighted blend. With humming stubbed, this is text-only.
    w_text, w_hum = 1.0, 0.0
    combined = {}
    for sid in _song_ids:
        combined[sid] = w_text * text_scores.get(sid, 0.0) + w_hum * hum_scores.get(sid, 0.0)

    ranked = sorted(combined.items(), key=lambda kv: -kv[1])
    ids = [sid for sid, _ in ranked]
    songs = {s["id"]: s for s in db.songs_by_ids(ids)}

    # Deduplicate by (title, artist) — keep highest-scoring copy
    out = []
    seen = set()
    for sid, sc in ranked:
        if sid not in songs:
            continue
        s = dict(songs[sid])
        key = (s["title"].lower(), s["artist"].lower())
        if key in seen:
            continue
        seen.add(key)
        s["score"] = round(sc, 4)
        out.append(s)
        if len(out) == top_k:
            break
    return out

```

### backend/db.py

```python
"""SQLite persistence layer.

One file, three tables:
  - songs           : the 20-song catalog (seeded once from songs_seed.py)
  - search_history  : every search the user runs (inputs + the 5 result ids)
  - saved_songs     : songs the user bookmarked

For a 20-song demo SQLite is plenty; the schema maps 1:1 onto Postgres later.
"""

import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path

from songs_seed import SONGS

DB_PATH = Path(__file__).parent / "songfinder.db"


def get_conn():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    conn = get_conn()
    cur = conn.cursor()

    cur.execute("""
        CREATE TABLE IF NOT EXISTS songs (
            id            INTEGER PRIMARY KEY,
            title         TEXT NOT NULL,
            artist        TEXT NOT NULL,
            year          INTEGER,
            genre         TEXT,           -- json array
            lyric_snippet TEXT,
            mood_keywords TEXT,           -- json array
            description   TEXT,
            preview_url   TEXT,           -- null when no playable audio
            melody_contour TEXT,          -- json array, phase 2
            artwork_url   TEXT            -- album/single cover art
        )
    """)
    # migrate existing DBs that pre-date artwork_url
    cols = [r[1] for r in cur.execute("PRAGMA table_info(songs)").fetchall()]
    if "artwork_url" not in cols:
        cur.execute("ALTER TABLE songs ADD COLUMN artwork_url TEXT")

    cur.execute("""
        CREATE TABLE IF NOT EXISTS search_history (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            label       TEXT,             -- short display label e.g. "80s jazz"
            genre       TEXT,
            lyric       TEXT,
            extra       TEXT,
            had_audio   INTEGER DEFAULT 0,
            result_ids  TEXT,             -- json array of song ids, in rank order
            created_at  TEXT
        )
    """)

    cur.execute("""
        CREATE TABLE IF NOT EXISTS saved_songs (
            song_id   INTEGER PRIMARY KEY,
            saved_at  TEXT,
            FOREIGN KEY (song_id) REFERENCES songs(id)
        )
    """)

    conn.commit()
    _seed_songs(conn)
    conn.close()


def _seed_songs(conn):
    cur = conn.cursor()
    cur.execute("SELECT COUNT(*) AS n FROM songs")
    if cur.fetchone()["n"] > 0:
        return
    for s in SONGS:
        cur.execute(
            """INSERT INTO songs
               (id, title, artist, year, genre, lyric_snippet,
                mood_keywords, description, preview_url, melody_contour)
               VALUES (?,?,?,?,?,?,?,?,?,?)""",
            (
                s["id"], s["title"], s["artist"], s["year"],
                json.dumps(s["genre"]), s["lyric_snippet"],
                json.dumps(s["mood_keywords"]), s["description"],
                s["preview_url"], json.dumps(s["melody_contour"]),
            ),
        )
    conn.commit()


def _row_to_song(row):
    return {
        "id": row["id"],
        "title": row["title"],
        "artist": row["artist"],
        "year": row["year"],
        "genre": json.loads(row["genre"] or "[]"),
        "lyric_snippet": row["lyric_snippet"],
        "mood_keywords": json.loads(row["mood_keywords"] or "[]"),
        "description": row["description"],
        "preview_url": row["preview_url"],
        "artwork_url": row["artwork_url"],
    }


def all_songs():
    conn = get_conn()
    rows = conn.execute("SELECT * FROM songs ORDER BY id").fetchall()
    conn.close()
    return [_row_to_song(r) for r in rows]


def songs_by_ids(ids):
    if not ids:
        return []
    conn = get_conn()
    placeholders = ",".join("?" * len(ids))
    rows = conn.execute(
        f"SELECT * FROM songs WHERE id IN ({placeholders})", ids
    ).fetchall()
    conn.close()
    by_id = {r["id"]: _row_to_song(r) for r in rows}
    return [by_id[i] for i in ids if i in by_id]  # preserve rank order


# ---------- history ----------

def add_history(label, genre, lyric, extra, had_audio, result_ids):
    conn = get_conn()
    conn.execute(
        """INSERT INTO search_history
           (label, genre, lyric, extra, had_audio, result_ids, created_at)
           VALUES (?,?,?,?,?,?,?)""",
        (label, genre, lyric, extra, int(had_audio),
         json.dumps(result_ids), datetime.now(timezone.utc).isoformat()),
    )
    conn.commit()
    conn.close()


def list_history():
    conn = get_conn()
    rows = conn.execute(
        "SELECT * FROM search_history ORDER BY created_at DESC"
    ).fetchall()
    conn.close()
    return [
        {
            "id": r["id"],
            "label": r["label"],
            "result_ids": json.loads(r["result_ids"] or "[]"),
            "created_at": r["created_at"],
        }
        for r in rows
    ]


def delete_history(history_id):
    conn = get_conn()
    conn.execute("DELETE FROM search_history WHERE id = ?", (history_id,))
    conn.commit()
    conn.close()


def get_history_results(history_id):
    conn = get_conn()
    row = conn.execute(
        "SELECT result_ids FROM search_history WHERE id = ?", (history_id,)
    ).fetchone()
    conn.close()
    if not row:
        return []
    return songs_by_ids(json.loads(row["result_ids"] or "[]"))


# ---------- saved ----------

def save_song(song_id):
    conn = get_conn()
    conn.execute(
        "INSERT OR IGNORE INTO saved_songs (song_id, saved_at) VALUES (?, ?)",
        (song_id, datetime.now(timezone.utc).isoformat()),
    )
    conn.commit()
    conn.close()


def unsave_song(song_id):
    conn = get_conn()
    conn.execute("DELETE FROM saved_songs WHERE song_id = ?", (song_id,))
    conn.commit()
    conn.close()


def list_saved():
    conn = get_conn()
    rows = conn.execute(
        """SELECT s.* FROM saved_songs sv
           JOIN songs s ON s.id = sv.song_id
           ORDER BY sv.saved_at DESC"""
    ).fetchall()
    conn.close()
    return [_r
[truncated — 29 more characters]
```

### backend/songs_seed.py

```python
"""Seed data for the 20-song demo database.

Two kinds of entries:
  - Public-domain classical: composition is public domain, free CC/PD recordings
    exist (Musopen / IMSLP), so `preview_url` can point at a real playable clip.
  - Modern copyrighted songs: metadata only, searchable by text, but
    `preview_url` is None because hosting the recording would infringe copyright.

`melody_contour` is a placeholder for phase 2 (query-by-humming). Leave [] for now.
"""

SONGS = [
    # ---------- Public-domain classical (real audio possible) ----------
    {
        "id": 1,
        "title": "Eine kleine Nachtmusik (1st mvt)",
        "artist": "Wolfgang Amadeus Mozart",
        "year": 1787,
        "genre": ["classical", "serenade"],
        "lyric_snippet": "",
        "mood_keywords": ["elegant", "bright", "cheerful", "courtly", "playful"],
        "description": "Lively, instantly recognizable string serenade with a bouncy, optimistic main theme.",
        "preview_url": "PD_AUDIO",  # replace with hosted Musopen/IMSLP clip
        "melody_contour": [],
    },
    {
        "id": 2,
        "title": "Symphony No. 5 (1st mvt)",
        "artist": "Ludwig van Beethoven",
        "year": 1808,
        "genre": ["classical", "symphony"],
        "lyric_snippet": "",
        "mood_keywords": ["dramatic", "dark", "powerful", "fate", "intense"],
        "description": "Famous four-note 'da-da-da-dum' opening; stormy, driving, dramatic orchestral piece.",
        "preview_url": "PD_AUDIO",
        "melody_contour": [],
    },
    {
        "id": 3,
        "title": "Clair de Lune",
        "artist": "Claude Debussy",
        "year": 1905,
        "genre": ["classical", "impressionist", "piano"],
        "lyric_snippet": "",
        "mood_keywords": ["dreamy", "calm", "gentle", "moonlight", "soft", "romantic"],
        "description": "Soft, flowing solo piano piece evoking moonlight; calm and introspective.",
        "preview_url": "PD_AUDIO",
        "melody_contour": [],
    },
    {
        "id": 4,
        "title": "Spring (Four Seasons)",
        "artist": "Antonio Vivaldi",
        "year": 1725,
        "genre": ["classical", "baroque", "concerto"],
        "lyric_snippet": "",
        "mood_keywords": ["bright", "joyful", "energetic", "spring", "birdsong"],
        "description": "Cheerful baroque violin concerto imitating birdsong and the arrival of spring.",
        "preview_url": "PD_AUDIO",
        "melody_contour": [],
    },
    {
        "id": 5,
        "title": "Canon in D",
        "artist": "Johann Pachelbel",
        "year": 1680,
        "genre": ["classical", "baroque"],
        "lyric_snippet": "",
        "mood_keywords": ["serene", "uplifting", "wedding", "peaceful", "graceful"],
        "description": "Gently building, repeating chord progression; a calm, hopeful piece common at weddings.",
        "preview_url": "PD_AUDIO",
        "melody_contour": [],
    },
    {
        "id": 6,
        "title": "Ride of the Valkyries",
        "artist": "Richard Wagner",
        "year": 1856,
        "genre": ["classical", "opera", "orchestral"],
        "lyric_snippet": "",
        "mood_keywords": ["epic", "powerful", "heroic", "intense", "soaring"],
        "description": "Thunderous, soaring brass theme; grand and aggressive orchestral spectacle.",
        "preview_url": "PD_AUDIO",
        "melody_contour": [],
    },
    {
        "id": 7,
        "title": "Symphony No. 9 'Ode to Joy'",
        "artist": "Ludwig van Beethoven",
        "year": 1824,
        "genre": ["classical", "symphony", "choral"],
        "lyric_snippet": "Freude, schoner Gotterfunken",
        "mood_keywords": ["joyful", "triumphant", "uplifting", "hopeful", "grand"],
        "description": "Triumphant, soaring choral melody celebrating joy and human brotherhood.",
        "preview_url": "PD_AUDIO",
        "melody_contour": [],
    },
    {
        "id": 8,
        "title": "Hungarian Dance No. 5",
        "artist": "Johannes Brahms",
        "year": 1869,
        "genre": ["classical", "dance"],
        "lyric_snippet": "",
        "mood_keywords": ["fiery", "playful", "energetic", "dramatic", "folk"],
        "description": "Fast, fiery folk-flavored dance with sudden tempo changes; playful and dramatic.",
        "preview_url": "PD_AUDIO",
        "melody_contour": [],
    },
    {
        "id": 9,
        "title": "The Blue Danube",
        "artist": "Johann Strauss II",
        "year": 1866,
        "genre": ["classical", "waltz"],
        "lyric_snippet": "",
        "mood_keywords": ["graceful", "flowing", "elegant", "waltz", "sweeping"],
        "description": "Sweeping, elegant waltz with a lilting, instantly hummable main theme.",
        "preview_url": "PD_AUDIO",
        "melody_contour": [],
    },
    {
        "id": 10,
        "title": "Gymnopedie No. 1",
        "artist": "Erik Satie",
        "year": 1888,
        "genre": ["classical", "piano", "minimalist"],
        "lyric_snippet": "",
        "mood_keywords": ["melancholy", "slow", "calm", "wistful", "spacious"],
        "description": "Slow, sparse, melancholic solo piano; quietly reflective and bittersweet.",
        "preview_url": "PD_AUDIO",
        "melody_contour": [],
    },

    # ---------- Modern, copyrighted (metadata only, no audio) ----------
    {
        "id": 11,
        "title": "Billie Jean",
        "artist": "Michael Jackson",
        "year": 1982,
        "genre": ["pop", "funk", "dance"],
        "lyric_snippet": "Billie Jean is not my lover",
        "mood_keywords": ["groovy", "danceable", "tense", "iconic", "bassline"],
        "description": "Funky, driving bassline-led pop song with a tense, danceable groove.",
        "preview_url": None,
        "melody_contour": [],
    },
    {
        "id": 12,
        "title": "Bohemian Rhapsody",
        "artist": "Queen",
        "year": 1975,
        "genre": ["rock", "progressive"],
        "lyric_snippet": "Is this the real life? Is this just fantasy?",
        
[truncated — 3757 more characters]
```

### frontend/lib/theme.js

```javascript
export const C = {
  primary:      "#6156E2",
  primaryLight: "#EEEDFC",
  bg:           "#F6F6FA",
  card:         "#FFFFFF",
  text:         "#0E0E16",
  sub:          "#6B6B80",
  placeholder:  "#AAAAB8",
  border:       "#E6E6F0",
  danger:       "#E24B4A",
};

```

### frontend/lib/api.js

```javascript
import Constants from "expo-constants";

// Automatically use the same host Expo is running on.
// Works on physical devices, simulators, and web without any manual IP changes.
const host = Constants.expoConfig?.hostUri?.split(":")[0] ?? "localhost";
const BASE_URL = `http://${host}:8000`;

export async function search({ genre, lyric, extra, audioUri }) {
  const form = new FormData();
  form.append("genre", genre || "");
  form.append("lyric", lyric || "");
  form.append("extra", extra || "");
  if (audioUri) {
    form.append("audio", {
      uri: audioUri,
      name: "hum.m4a",
      type: "audio/m4a",
    });
  }
  const res = await fetch(`${BASE_URL}/search`, { method: "POST", body: form });
  const data = await res.json();
  return data.results;
}

export async function getHistory() {
  const res = await fetch(`${BASE_URL}/history`);
  return (await res.json()).history;
}

export async function getHistoryResults(id) {
  const res = await fetch(`${BASE_URL}/history/${id}`);
  return (await res.json()).results;
}

export async function deleteHistory(id) {
  await fetch(`${BASE_URL}/history/${id}`, { method: "DELETE" });
}

export async function getSaved() {
  const res = await fetch(`${BASE_URL}/saved`);
  return (await res.json()).saved;
}

export async function saveSong(id) {
  await fetch(`${BASE_URL}/saved/${id}`, { method: "POST" });
}

export async function unsaveSong(id) {
  await fetch(`${BASE_URL}/saved/${id}`, { method: "DELETE" });
}

```

[5 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]