A full-stack demo showing how Google Gemini Embedding 2 audio embeddings work in practice: upload or record a sound, and the system finds the most similar music tracks by comparing raw audio vectors (not text metadata)
audio query βββΊ Gemini Embedding 2 βββΊ Qdrant similarity search βββΊ top matches
β
ββββββββββββ΄βββββββββββ
Spotify API YouTube API
(enrichment) (enrichment)
Demo.mp4
- Quick Start (5 minutes)
- API Key Setup β Google, Spotify, YouTube
- Generate a Sample Audio File
- Local Development (without Docker)
- Docker Compose
- How It Works (technical)
- Project Structure
- Environment Variables
- API Endpoints
- Tests
- PoC Limitations
Minimum requirements:
- Python 3.11+, Node 18+, Docker (for Qdrant only),
uv,ffmpeg - A Google API key with Gemini access (get one here) β free tier is sufficient
# 1. Install uv (fast Python package manager)
curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Install ffmpeg
brew install ffmpeg # macOS
# sudo apt install ffmpeg # Ubuntu/Debian
# 3. Clone the repo
git clone <repo-url>
cd audio-to-anything-demo
# 4. Configure API keys (see section below for how to get them)
cp .env.example .env
# Open .env and set GOOGLE_API_KEY=...
# 5. Start Qdrant
docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant
# 6. Start the backend
cd backend
uv venv && source .venv/bin/activate
uv pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000 &
# 7. Generate a sample audio file from the dataset
python scripts/save_sample_audio.py
# β saves samples/sample_query.wav
# 8. Start the frontend (separate terminal)
cd ../frontend
npm install && npm run dev
# 9. Open http://localhost:5173
# β click "Load Dataset into Qdrant" (Sample mode, 30 tracks)
# β wait for completion (~3-5 min)
# β upload samples/sample_query.wav and click SearchGemini Embedding 2 is the core engine of this demo. Without this key nothing works.
- Go to Google AI Studio
- Click Create API key
- Select an existing Google Cloud project or create a new one
- Copy the key and add it to
.env:
GOOGLE_API_KEY=AIza...
Free quota: Gemini API free tier supports ~1,500 requests/minute β enough to ingest all 594 tracks in the dataset.
Model used:
gemini-embedding-2-previewβ natively multimodal (text, images, audio, video mapped into a single 3072-dim vector space). Currently in public preview; the model ID may change when it reaches GA.
Only needed to display Spotify cards (album cover, track link) on top matches. The app works fine without it and shows a non-blocking message in the UI.
- Go to the Spotify Developer Dashboard
- Click Create app
- Choose any name; set
http://localhostas the Redirect URI - Go to Settings β Client ID and Client Secret
- Add to
.env:
SPOTIFY_CLIENT_ID=a1b2c3...
SPOTIFY_CLIENT_SECRET=x9y8z7...
Uses the Client Credentials flow β no user login required, public track search only.
Only needed to show video embeds and Shorts on top matches. The app works fine without it.
- Go to Google Cloud Console
- Select (or create) a project
- Search for YouTube Data API v3 β Enable
- Go to APIs & Services β Credentials β Create Credentials β API key
- (Recommended) Add a restriction: YouTube Data API v3
- Add to
.env:
YOUTUBE_API_KEY=AIza...
Quota: each
search.listcall costs 100 units; the free tier gives 10,000 units/day (~100 searches). More than enough for demos.Note: the same Google key used for Gemini embeddings does not work for YouTube β they are separate APIs requiring separate credentials, even within the same GCP project.
# ββ Required ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
GOOGLE_API_KEY=AIza...
# ββ Qdrant (leave as-is for local) βββββββββββββββββββββββββββββββββββββββββββ
QDRANT_HOST=localhost
QDRANT_PORT=6333
QDRANT_COLLECTION=fma_audio
# ββ Embedding βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
EMBEDDING_MODEL=gemini-embedding-2-preview
EMBEDDING_DIM=3072
# ββ Ingest ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
INGEST_SAMPLE_SIZE=30
CHUNK_DURATION_SEC=30
# ββ Optional ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
YOUTUBE_API_KEY=To test search without hunting for an audio file, the script save_sample_audio.py downloads the first track from the dataset and saves the first 10 seconds as a WAV:
cd backend
source .venv/bin/activate
python scripts/save_sample_audio.pyOutput:
β Saved 10s clip β samples/sample_query.wav
β Metadata β samples/sample_query.json
Track info:
Title : ...
Artist : ...
Genre : dark, cinematic
Tags : tense, ambient, suspenseful, ...
Duration: 10.0s (sr=22050)
After ingesting the dataset, drag samples/sample_query.wav into the UI uploader and click Search. The source track will appear in the top results with a very high similarity score β because it is literally a fragment of one of the indexed tracks.
The script uses HuggingFace streaming: it downloads only the first track (a few MB), not the full 2 GB dataset.
| Tool | Min version | How to install |
|---|---|---|
| Python | 3.11 | python.org |
| Node.js | 18 | nodejs.org |
| Docker | any | docker.com β Qdrant only |
| uv | any | curl -LsSf https://astral.sh/uv/install.sh | sh |
| ffmpeg | any | brew install ffmpeg / apt install ffmpeg |
docker run -d --name qdrant -p 6333:6333 -p 6334:6334 qdrant/qdrantDashboard: http://localhost:6333/dashboard
cd backend
# Create venv and install dependencies
uv venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
uv pip install -r requirements.txt
# Start (with hot-reload)
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000Interactive API docs: http://localhost:8000/docs
cd frontend
npm install
npm run dev# In a separate terminal, with venv active:
cd backend
# Generate the sample file
python scripts/save_sample_audio.py
# Sample ingest (30 tracks, ~3-5 minutes)
python cli.py ingest --sample
# Full ingest (594 tracks, ~10-15 minutes)
python cli.py ingest --full
# Check status
python cli.py healthRe-running ingest is safe β points are upserted, not duplicated.
cp .env.example .env
# Fill in API keys in .env (see section above)
# Note: with Docker Compose use QDRANT_HOST=qdrant (not localhost)
docker compose up --buildServices:
- Frontend: http://localhost:5173
- Backend: http://localhost:8000
- Qdrant: http://localhost:6333/dashboard
The
hf_cachevolume mounts the HuggingFace cache inside the container, so the dataset is not re-downloaded on every restart.
Most music search systems match on metadata: title, artist, genre tags. That only works if you already know what you are looking for.
This demo does the opposite: it embeds the raw audio signal into a 3072-dimensional vector space using Gemini Embedding 2, a natively multimodal model that maps text, images, audio, and video into a single unified semantic space.
Two audio clips that sound similar β same rhythmic texture, same harmonic mood, same timbre β end up close in this space, regardless of title, artist, or declared genre. You can hum a melody, upload a snippet, or record ambient sound and get musically coherent matches back.
Spotify and YouTube are a second optional enrichment step that fires after the audio match is found.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β INGEST (one-time) β
β β
β HuggingFace dataset β
β atoof/fma-music-descriptions β
β β β
β βΌ β
β fma_loader.py βββΊ audio array (numpy) β
β β β
β βΌ β
β audio_utils.py βββΊ 30s WAV chunks β
β β (FMA tracks are minutes long; β
β β Gemini hard limit: 80s/file) β
β βΌ β
β embedding_service.py β
β gemini-embedding-2-preview β
β Part.from_bytes(wav, "audio/wav") β
β β β
β βΌ [3072 floats] β
β qdrant_service.py βββΊ upsert point β
β payload: track_id, title, artist, β
β description, tags, genre_tags, β
β chunk_start_sec, chunk_end_sec β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SEARCH (per query) β
β β
β user audio file (upload / microphone) β
β β β
β βΌ β
β audio_utils.ensure_wav() βββΊ WAV bytes β
β β β
β βΌ β
β embedding_service.embed_audio() β
β β query vector [3072 floats] β
β β β
β βΌ β
β qdrant_service.search(vector, top_k) β
β β chunk hits with cosine scores β
β β β
β βΌ β
β aggregate by track_id β
β β top_chunks + top_tracks β
β β β
β ββββββ΄βββββ β
β βΌ βΌ β
β Spotify YouTube β
β search() search() β
β title + title + β
β artist artist β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Parameter | Value |
|---|---|
| Model ID | gemini-embedding-2-preview |
| Vector dimension | 3072 (configurable via EMBEDDING_DIM) |
| Supported audio input | audio/wav, audio/mp3 |
| Max duration per call | 80 seconds |
| Files per call | 1 |
| Python SDK | google-genai>=1.9.0 |
| Distance metric in Qdrant | Cosine |
# How it is called in embedding_service.py
from google import genai
from google.genai import types
response = client.models.embed_content(
model="gemini-embedding-2-preview",
contents=[
types.Part.from_bytes(data=wav_bytes, mime_type="audio/wav")
],
config=types.EmbedContentConfig(output_dimensionality=3072),
)
vector = response.embeddings[0].values # list[float], len=3072- 594 tracks from the Free Music Archive, CC BY 4.0 license
- MP3 audio embedded directly in Parquet β no YouTube downloads, just
load_dataset() - Rich metadata per track:
title,artist,description(mood/energy/instrumentation),tags(275 unique),genre_tags - Total size ~2.1 GB; in sample mode only the first N tracks are fetched via streaming
Many FMA tracks are several minutes long. Since Gemini accepts at most 80 seconds per call, each track is split into CHUNK_DURATION_SEC-second segments (default 30s). Each chunk becomes a separate Qdrant point with chunk_start_sec and chunk_end_sec in its payload. At search time, results are aggregated by track_id, returning both best-chunk and best-track views.
Qdrant document example
These are an optional second step that fires after the audio similarity search.
We use title + artist from the Qdrant payload to run a text search in each API.
This is not audio fingerprinting β it is metadata enrichment layered on top of the audio match.
audio-to-anything-demo/
βββ .env.example
βββ .gitignore
βββ docker-compose.yml
βββ README.md
βββ samples/ β generated by save_sample_audio.py
β βββ sample_query.wav
β βββ sample_query.json
βββ backend/
β βββ Dockerfile
β βββ requirements.txt
β βββ cli.py β CLI: ingest --sample/--full, health
β βββ scripts/
β β βββ save_sample_audio.py β generates test file from dataset
β βββ app/
β βββ main.py β FastAPI app, CORS, lifespan
β βββ config.py β pydantic-settings, env vars
β βββ models.py β Pydantic request/response models
β βββ routers/
β β βββ health.py β GET /health
β β βββ ingest.py β POST /ingest, GET /ingest/progress
β β βββ search.py β POST /search/audio
β β βββ spotify.py β POST /spotify/search
β β βββ youtube.py β POST /youtube/search
β βββ services/
β β βββ audio_utils.py β ensure_wav, chunk_audio, get_duration
β β βββ embedding_service.pyβ Gemini Embedding 2, retry, backoff
β β βββ qdrant_service.py β collection CRUD, upsert, search
β β βββ ingest_service.py β orchestration, background thread, progress
β β βββ spotify_service.py β client credentials, token cache
β β βββ youtube_service.py β Data API v3, Shorts detection
β βββ dataset_loaders/
β β βββ fma_loader.py β HF streaming, WAV conversion
β βββ tests/
β βββ test_health.py
β βββ test_embedding.py
β βββ test_ingest.py
βββ frontend/
βββ Dockerfile
βββ package.json
βββ vite.config.js
βββ index.html
βββ src/
βββ App.jsx
βββ App.css
βββ main.jsx
βββ api/client.js
βββ components/
βββ DatasetLoader.jsx β sample/full mode, live progress stats
βββ AudioUploader.jsx β drag-and-drop, file input
βββ Recorder.jsx β MediaRecorder API, timer
βββ SearchResults.jsx β tabs: Internal / Spotify / YouTube
βββ SpotifyResults.jsx β album cover, preview audio, Spotify link
βββ YouTubeResults.jsx β video embed, thumbnail, Shorts badge
βββ ProgressBar.jsx
βββ TryDemoQuery.jsx β generates a demo tone in-browser
| Variable | Required | Default | Description |
|---|---|---|---|
GOOGLE_API_KEY |
Yes | β | Gemini API key (get one here) |
QDRANT_HOST |
No | localhost |
Qdrant hostname (qdrant with Docker Compose) |
QDRANT_PORT |
No | 6333 |
Qdrant port |
QDRANT_COLLECTION |
No | fma_audio |
Collection name |
EMBEDDING_MODEL |
No | gemini-embedding-2-preview |
Gemini model ID |
EMBEDDING_DIM |
No | 3072 |
Vector dimension (reduce to 768 for speed) |
INGEST_SAMPLE_SIZE |
No | 30 |
Tracks in sample mode |
CHUNK_DURATION_SEC |
No | 30 |
Seconds per audio chunk |
SPOTIFY_CLIENT_ID |
No | β | Spotify app client ID |
SPOTIFY_CLIENT_SECRET |
No | β | Spotify app client secret |
YOUTUBE_API_KEY |
No | β | YouTube Data API v3 key |
| Method | Path | Description |
|---|---|---|
GET |
/health |
Backend + Qdrant status |
POST |
/ingest |
Start ingest {"sample": true, "sample_size": 30} |
GET |
/ingest/progress |
Poll ingest progress |
POST |
/search/audio |
Upload audio file, returns top-k matches |
POST |
/spotify/search |
Search Spotify {"title": "...", "artist": "..."} |
POST |
/youtube/search |
Search YouTube {"title": "...", "artist": "..."} |
Interactive Swagger docs: http://localhost:8000/docs
cd backend
source .venv/bin/activate
pytest tests/ -vAll tests mock external APIs (Gemini, Qdrant, Spotify, YouTube) β no keys or network required.
- Gemini Embedding 2 is in public preview (March 2026) β the model ID
gemini-embedding-2-previewmay change when it reaches GA - Gemini rate limits: ~1,500 req/min on the free tier; full ingest of 594 chunked tracks generates ~1,000+ calls and takes 5β15 minutes
- In-memory progress state: restarting the backend resets the progress counter, but Qdrant points persist and upsert is idempotent
- Shorts detection: heuristic based on title keywords, not YouTube's official Shorts classification API
- Spotify/YouTube enrichment: uses title+artist as a text query, not audio fingerprinting
- Long audio: files longer than
CHUNK_DURATION_SECare split into chunks; only the first chunk is used as the search query