Compare commits
33 Commits
jms
...
b8c955570e
| Author | SHA1 | Date | |
|---|---|---|---|
| b8c955570e | |||
| 4a45e82abe | |||
| b94f79f408 | |||
| 7d5ad306b4 | |||
| 2efc785694 | |||
| 3ddf854123 | |||
| a1048b0e55 | |||
| 2ce6d1e244 | |||
| dec0fc1595 | |||
| b989238373 | |||
| 3aadf1afbb | |||
| 22b8f1fe04 | |||
| e3e4554d1f | |||
| 3a7b6b24e0 | |||
| 49df62ff73 | |||
| 4500d0c209 | |||
| a871ad5325 | |||
| 105c5b3334 | |||
| 9c5ec89948 | |||
| b30267a50a | |||
| 6111cfedd1 | |||
| 7a108ad0e2 | |||
| c08cd619f7 | |||
| f133abfdee | |||
| 0eb00784a6 | |||
| ca6b901bc8 | |||
| d3a2840454 | |||
| cfa7b877f6 | |||
| 4ed382393e | |||
| 1697a14716 | |||
| 8f96afe015 | |||
| 850e87f90f | |||
| 10962077bf |
@@ -1,10 +0,0 @@
|
|||||||
# AI mode: "offline" uses local Ollama, "online" uses Claude API
|
|
||||||
AI_MODE=offline
|
|
||||||
|
|
||||||
# Online mode - Anthropic Claude
|
|
||||||
ANTHROPIC_API_KEY=your_key_here
|
|
||||||
CLAUDE_MODEL=claude-sonnet-4-6
|
|
||||||
|
|
||||||
# Offline mode - Ollama (run: ollama pull llama3.2:3b)
|
|
||||||
OLLAMA_HOST=http://localhost:11434
|
|
||||||
OLLAMA_MODEL=llama3.2:3b
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"default": "itsamejms"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false # Ensures one OS failing doesn't kill the others
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: windows-latest
|
||||||
|
command: npm run electron:build:win
|
||||||
|
artifact_pattern: "release/*.exe"
|
||||||
|
- os: macos-latest
|
||||||
|
# Added --universal here if you want to support both Intel and Apple Silicon
|
||||||
|
command: npm run electron:build:mac -- --universal
|
||||||
|
artifact_pattern: "release/*.dmg"
|
||||||
|
- os: ubuntu-latest
|
||||||
|
command: npm run electron:build:linux
|
||||||
|
artifact_pattern: "release/*.AppImage"
|
||||||
|
|
||||||
|
env:
|
||||||
|
# This fixes the "GitHub Personal Access Token is not set" error
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# Prevents errors related to missing code-signing certificates
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build Application
|
||||||
|
run: ${{ matrix.command }}
|
||||||
|
|
||||||
|
- name: Upload Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: artifacts-${{ matrix.os }}
|
||||||
|
path: ${{ matrix.artifact_pattern }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
# This downloads all "artifacts-*" into a folder named 'all-outputs'
|
||||||
|
path: all-outputs
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: List files for debugging
|
||||||
|
run: ls -R all-outputs
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
# Point directly to the folder where all OS builds are merged
|
||||||
|
files: all-outputs/*
|
||||||
|
generate_release_notes: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
# Python
|
# Dependencies
|
||||||
__pycache__/
|
node_modules/
|
||||||
*.py[oc]
|
frontend/node_modules/
|
||||||
build/
|
|
||||||
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
wheels/
|
build/
|
||||||
*.egg-info
|
|
||||||
.venv/
|
|
||||||
.python-version
|
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
debug.log
|
debug.log
|
||||||
|
|
||||||
# Frontend
|
releases/
|
||||||
frontend/node_modules/
|
release/
|
||||||
frontend/dist/
|
.DS_Store
|
||||||
|
|
||||||
|
# Firebase
|
||||||
|
.firebase/
|
||||||
|
firebase-debug.log
|
||||||
|
ui-debug.log
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development (Vite dev server + Electron window with hot reload)
|
||||||
|
npm run electron:dev
|
||||||
|
|
||||||
|
# Browser-only dev (no Electron)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build installers
|
||||||
|
npm run electron:build:win # Windows NSIS installer → releases/
|
||||||
|
npm run electron:build:mac # macOS DMG → releases/
|
||||||
|
npm run electron:build:linux # Linux AppImage → releases/
|
||||||
|
```
|
||||||
|
|
||||||
|
No test suite exists. There is no lint script — no ESLint config is present.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Electron shell** (`electron/main.cjs`) loads `dist/index.html` in production or `localhost:5173` in dev. The renderer process has full Web Audio API access (`sandbox: false`). `preload.cjs` uses `contextIsolation: true` with no exposed IPC — Electron is purely a window host; all logic lives in the renderer.
|
||||||
|
|
||||||
|
**Two audio pipelines run in parallel** inside `AudioCapture.jsx`:
|
||||||
|
|
||||||
|
| Path | FFT | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Pitch | 4096 samples (~90ms, `smoothingTimeConstant=0.0`) | McLeod pitch detection via `pitchy` → feeds key detection |
|
||||||
|
| Chord | 16384 samples (~370ms, `smoothingTimeConstant=0.5`) | Harmonic summation chroma → feeds chord detection |
|
||||||
|
|
||||||
|
The 16384 FFT gives 2.7 Hz/bin resolution, which is necessary to separate adjacent semitones on low guitar strings (~5-6 Hz apart). The chord analyser uses `computeChroma()` — a harmonic summation that folds each FFT bin back through 5 harmonics to cancel overtone contamination (prevents minor chords from reading as major).
|
||||||
|
|
||||||
|
**State and detection logic lives entirely in `App.jsx`:**
|
||||||
|
|
||||||
|
- `handleNote` (pitch callback) → accumulates `noteHistoryRef`, runs Krumhansl-Schmuckler key detection every 5 notes, votes in `keyVotesRef` (rolling window, requires strong consensus before committing)
|
||||||
|
- `handleChroma` (chord callback) → averages a ring buffer of `chromaSmooth` frames, runs a **chroma stability gate** (per-bin variance check — bails if still in transition), then matches against chord templates via `matchChordFromChroma`, votes in `chordVotesRef`
|
||||||
|
- `handleOnset` (onset callback from RMS spike detection) → builds a **tempo histogram** from pairwise inter-onset intervals, folding all intervals into 55–220 BPM range; the histogram peak drives BPM display
|
||||||
|
|
||||||
|
Both `handleNote` and `handleChroma` use `useCallback(fn, [])` (empty deps). All values they need from render scope are kept in refs synced via `useEffect` — this prevents `AudioCapture`'s `start` from recreating on every render.
|
||||||
|
|
||||||
|
**All music theory is in `src/lib/theory.js`:**
|
||||||
|
|
||||||
|
- `detectKey` / `detectTopKeys` — Krumhansl-Schmuckler correlation against major/minor profiles only (K-S cannot distinguish modes — Dorian vs natural minor look the same; user manually picks mode)
|
||||||
|
- `matchChordFromChroma` — weighted coverage score (inEnergy / (inEnergy + outEnergy×0.7)), requires root presence (`chroma[r] >= 0.08`), margin over second-best, diatonic/bass bonuses
|
||||||
|
- `MATCH_CHORD_TYPES` — the subset of chord types used in real-time detection (not all of `CHORD_TYPES`)
|
||||||
|
- `detectRepeatingProgression` — non-overlapping pattern match over last 20 chords, length 2–6
|
||||||
|
|
||||||
|
**`src/services/audioService.js`** is a self-contained tuner hook (`useAudioTuner`) used only by `Tuner.jsx`. It uses its own separate `AudioContext` with simple autocorrelation — independent from the main pitch/chord pipeline.
|
||||||
|
|
||||||
|
## Design tokens (Tailwind)
|
||||||
|
|
||||||
|
Defined in `tailwind.config.js`: `bg-surface` (#0f0f0f), `bg-panel` (#1a1a1a), `border-border` (#2a2a2a), `text-accent` / `bg-accent` (#a855f7 purple). Use these rather than raw hex in components.
|
||||||
|
|
||||||
|
## Key configuration (`DEFAULTS` in `App.jsx`)
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `chromaSmooth` | Ring buffer size (frames averaged before chord check) |
|
||||||
|
| `chordVoteThreshold` | Consecutive matching chord frames required to commit |
|
||||||
|
| `chordMinScore` | Minimum coverage score from `matchChordFromChroma` |
|
||||||
|
| `keyVoteWindow` / `keyVoteThreshold` | Rolling window size and consensus count for key lock |
|
||||||
|
| `noteHistorySize` | Max pitch-class history kept for K-S key detection |
|
||||||
|
|
||||||
|
These are exposed in `Settings.jsx` as sliders. `configRef` keeps a ref in sync so stable callbacks can read current values.
|
||||||
|
|
||||||
|
## Instrument views
|
||||||
|
|
||||||
|
`Fretboard.jsx` and `Piano.jsx` are SVG-rendered visualisers. Both accept `keyInfo`, `currentChord`, and `monoColor`. They call `getPentatonicScale`, `getFullScale`, `getChordTones` from `theory.js` and colour notes by tier: chord tone (purple `#a855f7`) > pentatonic (amber or light purple in mono) > scale (dark gray or lightest purple in mono).
|
||||||
|
|
||||||
|
## GitHub Actions
|
||||||
|
|
||||||
|
`.github/workflows/release.yml` builds Windows and macOS installers on tagged pushes (`v*`) using `softprops/action-gh-release@v2`. Build scripts use `--publish never` to prevent electron-builder's own publish step.
|
||||||
@@ -1,48 +1,140 @@
|
|||||||
# WhatTheFlat
|
# WhatTheFlat
|
||||||
|
|
||||||
Real-time key and chord detection for musicians. Play guitar, bass, piano, or any instrument into your microphone and WhatTheFlat will identify the key you're in, the chords you're playing, and suggest progressions.
|
Real-time key and chord detection for musicians. Play guitar, bass, piano, or any instrument into your microphone and WhatTheFlat will identify the key you're in, the chords you're playing, and suggest progressions. Runs fully offline as a native desktop app.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Real-time chord detection from live audio
|
- Real-time chord detection from live audio (guitar, bass, piano, full band)
|
||||||
- Automatic key detection (Krumhansl-Schmuckler profiles)
|
- Automatic key detection with top-3 candidate display — click to lock
|
||||||
- Chord history and repeating progression detection
|
- Chord history and repeating progression detection
|
||||||
- Roman numeral analysis relative to detected key
|
- Roman numeral analysis relative to detected key
|
||||||
- Fretboard visualiser showing safe notes and chord tones
|
- Fretboard visualiser showing safe notes and chord tones
|
||||||
- Beginner / Advanced modes
|
- Beginner / Advanced modes
|
||||||
- Manual key lock for jam sessions
|
- Manual key lock for jam sessions
|
||||||
- AI chat assistant for music theory questions
|
- Supports borrowed/chromatic chords (e.g. D7 in A minor) in Advanced mode
|
||||||
|
- Fully offline — no internet connection required
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend**: React 18, Vite, Tailwind CSS
|
| | |
|
||||||
- **Audio**: Web Audio API, [Pitchy](https://github.com/ianprime0509/pitchy) (McLeod pitch detection)
|
|---|---|
|
||||||
- **Backend**: Python (Claude API for chat assistant)
|
| **App shell** | Electron |
|
||||||
|
| **UI** | React 18, Tailwind CSS, Vite |
|
||||||
|
| **Audio** | Web Audio API, [Pitchy](https://github.com/ianprime0509/pitchy) (McLeod pitch detection) |
|
||||||
|
| **Music theory** | Custom JS — Krumhansl-Schmuckler key detection, chroma-based chord matching |
|
||||||
|
|
||||||
## Getting Started
|
### Dependencies (`frontend/package.json`)
|
||||||
|
|
||||||
### Frontend
|
**Runtime**
|
||||||
|
- `react` / `react-dom` — UI
|
||||||
|
- `pitchy` — pitch detection
|
||||||
|
|
||||||
|
**Dev / build**
|
||||||
|
- `electron` — desktop runtime
|
||||||
|
- `electron-builder` — installer packaging
|
||||||
|
- `vite` + `@vitejs/plugin-react` — bundler
|
||||||
|
- `tailwindcss` + `autoprefixer` + `postcss` — styling
|
||||||
|
- `concurrently` — run Vite + Electron together in dev
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run electron:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5173` in your browser and click **Start Listening**. Allow microphone access when prompted.
|
Starts the Vite dev server and opens the Electron window simultaneously. The window connects to `localhost:5173` and supports hot reload.
|
||||||
|
|
||||||
### Backend (chat assistant)
|
## Building an Installer
|
||||||
|
|
||||||
|
Add app icons to `frontend/assets/` first:
|
||||||
|
- `icon.ico` — Windows
|
||||||
|
- `icon.icns` — macOS
|
||||||
|
- `icon.png` — Linux (256×256 minimum)
|
||||||
|
|
||||||
|
Then build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
cd frontend
|
||||||
python main.py
|
|
||||||
|
# Windows installer (NSIS)
|
||||||
|
npm run electron:build:win
|
||||||
|
|
||||||
|
# macOS DMG
|
||||||
|
npm run electron:build:mac
|
||||||
|
|
||||||
|
# Linux AppImage
|
||||||
|
npm run electron:build:linux
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Output is placed in `frontend/release/`.
|
||||||
|
|
||||||
|
## Releasing / Tagging
|
||||||
|
|
||||||
|
To create a GitHub release and trigger the CI build pipeline, create an annotated tag and push it to origin. The release workflow runs on tags matching `v*` (for example `v0.6.1`).
|
||||||
|
|
||||||
|
Local tagging example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# update package.json version first if desired
|
||||||
|
git tag -a v0.6.1 -m "Release v0.6.1"
|
||||||
|
git push origin v0.6.1
|
||||||
|
```
|
||||||
|
|
||||||
|
What the GitHub Action does (`.github/workflows/release.yml`):
|
||||||
|
|
||||||
|
- Listens for pushed tags `v*` and runs a matrix build across Windows, macOS and Linux.
|
||||||
|
- macOS is built as a universal binary (`--universal`) so a single DMG supports both Intel and Apple Silicon.
|
||||||
|
- Each matrix job builds the installer using `electron-builder`, uploads its artifacts, and a final `publish` job aggregates all artifacts into one GitHub release.
|
||||||
|
|
||||||
|
If you prefer to run builds locally before tagging, use the npm scripts in the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows NSIS
|
||||||
|
npm run electron:build:win
|
||||||
|
|
||||||
|
# macOS DMG (universal)
|
||||||
|
npm run electron:build:mac -- --universal
|
||||||
|
|
||||||
|
# Linux AppImage
|
||||||
|
npm run electron:build:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
CI notes / troubleshooting
|
||||||
|
- The workflow uploads artifacts from `release/` into the release. Ensure `package.json` build `directories.output` matches the workflow's expected `release/` folder.
|
||||||
|
- If mac packaging for x64 on ARM-hosted runners fails, switch to `--universal` (already configured) or build x64 on an Intel runner.
|
||||||
|
- To test the workflow locally, consider using `nektos/act` or push a temporary tag like `vtest`.
|
||||||
|
|
||||||
|
## Design Tokens
|
||||||
|
|
||||||
|
All colors are defined in `frontend/tailwind.config.js` and can be referenced by name in any component.
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `surface` | `#0f0f0f` | Page / app background |
|
||||||
|
| `panel` | `#1a1a1a` | Cards, panels, dialogs |
|
||||||
|
| `border` | `#2a2a2a` | Borders, dividers, muted backgrounds |
|
||||||
|
| `accent` | `#a855f7` | Primary interactive color (purple) |
|
||||||
|
| `amber` | `#f59e0b` | Roman numerals, secondary highlights |
|
||||||
|
| *(base text)* | `#f5f5f5` | Default body text |
|
||||||
|
|
||||||
|
Tailwind usage examples: `bg-surface`, `bg-panel`, `border-border`, `text-accent`, `bg-accent/20` (20% opacity).
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
Audio is processed in two parallel paths:
|
All processing happens locally in the Electron window — no server, no network calls.
|
||||||
|
|
||||||
1. **Pitch path** — small 4096-sample FFT with McLeod autocorrelation for fast, accurate single-note pitch detection. Feeds the key detection algorithm.
|
Audio is captured via the browser's Web Audio API and processed in two parallel paths:
|
||||||
2. **Chord path** — large 16384-sample FFT (2.7 Hz/bin resolution) with harmonic summation chroma extraction. The chroma vector is matched against chord templates (major, minor, dominant 7th, sus4, diminished) to identify the current chord.
|
|
||||||
|
|
||||||
Key detection uses a rolling vote over the last 12 detections and requires 9/12 agreement before committing, keeping the display stable during transitions.
|
1. **Pitch path** — 4096-sample FFT with McLeod autocorrelation for fast single-note pitch detection. Feeds the Krumhansl-Schmuckler key detection algorithm, which votes over a rolling window of 12 detections and requires 9/12 agreement before committing to a key.
|
||||||
|
|
||||||
|
2. **Chord path** — 16384-sample FFT (2.7 Hz/bin) with harmonic summation chroma extraction across 80–4000 Hz. The averaged chroma vector is matched against chord templates (major, minor, dom7, min7, dim, half-dim, aug, sus4, add9) using a weighted coverage score. Consecutive identical detections are required before a chord is committed, preventing transient false positives.
|
||||||
|
|
||||||
|
The top-3 key candidates are shown in real time as clickable chips. Locking a key in Beginner mode restricts chord matching to the 7 diatonic chords; Advanced mode allows chromatic/borrowed chords.
|
||||||
|
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
- firebase login
|
||||||
|
- npm run build
|
||||||
|
- firebase deploy --only hosting:jambuddy
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from typing import Literal
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
|
||||||
|
|
||||||
ai_mode: Literal["online", "offline"] = "offline"
|
|
||||||
|
|
||||||
# Online (Claude) settings
|
|
||||||
anthropic_api_key: str = ""
|
|
||||||
claude_model: str = "claude-sonnet-4-6"
|
|
||||||
|
|
||||||
# Offline (Ollama) settings
|
|
||||||
ollama_host: str = "http://localhost:11434"
|
|
||||||
ollama_model: str = "llama3.2:3b"
|
|
||||||
|
|
||||||
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from api.services.ai import chat
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class Message(BaseModel):
|
|
||||||
role: str
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
|
||||||
messages: list[Message]
|
|
||||||
context: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ChatResponse(BaseModel):
|
|
||||||
reply: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat", response_model=ChatResponse)
|
|
||||||
async def chat_endpoint(request: ChatRequest):
|
|
||||||
try:
|
|
||||||
reply = await chat(
|
|
||||||
messages=[m.model_dump() for m in request.messages],
|
|
||||||
context=request.context,
|
|
||||||
)
|
|
||||||
return ChatResponse(reply=reply)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
from api.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """You are a friendly, beginner-aware music theory assistant for WhatTheFlat — a live jam helper app.
|
|
||||||
You help musicians understand what's happening harmonically during a jam session.
|
|
||||||
Keep answers short, warm, and jargon-free. If you use a music term, briefly explain it.
|
|
||||||
Never say someone played a "wrong" note — always frame it as "try these instead".
|
|
||||||
When given context about the current key and chord, use it in your response."""
|
|
||||||
|
|
||||||
|
|
||||||
async def chat(messages: list[dict], context: dict | None = None) -> str:
|
|
||||||
system = SYSTEM_PROMPT
|
|
||||||
if context:
|
|
||||||
parts = []
|
|
||||||
if context.get("key"):
|
|
||||||
parts.append(f"Current key: {context['key']}")
|
|
||||||
if context.get("chord"):
|
|
||||||
parts.append(f"Current chord: {context['chord']}")
|
|
||||||
if parts:
|
|
||||||
system += "\n\nLive session context:\n" + "\n".join(parts)
|
|
||||||
|
|
||||||
if settings.ai_mode == "online":
|
|
||||||
return await _chat_claude(messages, system)
|
|
||||||
else:
|
|
||||||
return await _chat_ollama(messages, system)
|
|
||||||
|
|
||||||
|
|
||||||
async def _chat_claude(messages: list[dict], system: str) -> str:
|
|
||||||
import anthropic
|
|
||||||
|
|
||||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
|
||||||
response = await client.messages.create(
|
|
||||||
model=settings.claude_model,
|
|
||||||
max_tokens=512,
|
|
||||||
system=system,
|
|
||||||
messages=messages,
|
|
||||||
)
|
|
||||||
return response.content[0].text
|
|
||||||
|
|
||||||
|
|
||||||
async def _chat_ollama(messages: list[dict], system: str) -> str:
|
|
||||||
import ollama
|
|
||||||
|
|
||||||
client = ollama.AsyncClient(host=settings.ollama_host)
|
|
||||||
full_messages = [{"role": "system", "content": system}] + messages
|
|
||||||
response = await client.chat(
|
|
||||||
model=settings.ollama_model,
|
|
||||||
messages=full_messages,
|
|
||||||
)
|
|
||||||
return response.message.content
|
|
||||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,307 @@
|
|||||||
|
# Plan: PWA + Firebase Hosting for JamBuddy
|
||||||
|
|
||||||
|
Status: **proposed**. Each phase is independently shippable; stop after any one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Why this is low-risk
|
||||||
|
|
||||||
|
The Electron shell (`electron/main.cjs`, `preload.cjs`) does **nothing but host a window** — no IPC, no native APIs, `sandbox:false` only to give the renderer full Web Audio. All logic lives in the React renderer, which already runs browser-only via `npm run dev`. So the web build is the same `vite build` output the desktop installer already ships; we just add a manifest + service worker and serve `dist/` over HTTPS. Firebase gives us HTTPS for free, which `getUserMedia` (the mic) requires — that's the only hard web constraint.
|
||||||
|
|
||||||
|
Nothing in the app makes network calls today, so there's no API surface to re-host and no secrets to manage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — PWA scaffolding (no Firebase yet)
|
||||||
|
|
||||||
|
Goal: `vite build` produces an installable, offline-capable web app served from any static host.
|
||||||
|
|
||||||
|
### 1.1 Add `vite-plugin-pwa`
|
||||||
|
|
||||||
|
Only new dependency. Wraps Workbox under a Vite plugin — generates the service worker and injects the manifest. Cheaper than hand-rolling a SW and gets update flow for free.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// vite.config.js
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'JamBuddy — WhatTheFlat',
|
||||||
|
short_name: 'JamBuddy',
|
||||||
|
description: 'Real-time key and chord detection for live jams.',
|
||||||
|
theme_color: '#0f0f0f', // matches bg-surface token
|
||||||
|
background_color: '#0f0f0f',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
icons: [ /* see 1.3 */ ],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
|
||||||
|
// Audio is captured live — nothing to cache from a remote. Precache the app shell only.
|
||||||
|
navigateFallback: '/index.html',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Create `public/` directory
|
||||||
|
|
||||||
|
Vite serves `public/` at root and copies it verbatim into `dist/`. We need it for:
|
||||||
|
|
||||||
|
- `favicon.ico`, `apple-touch-icon.png` (180×180)
|
||||||
|
- Any static PWA icons referenced by the manifest
|
||||||
|
- (Optional) `robots.txt` — not needed for an app, skip.
|
||||||
|
|
||||||
|
The existing `src/assets/whattheflat-logo.png` is imported in `App.jsx` and bundled by Vite — leave that as-is; **don't** move it. The manifest icons must live in `public/` because they're referenced by URL, not imported.
|
||||||
|
|
||||||
|
### 1.3 Generate icon set from the existing logo
|
||||||
|
|
||||||
|
Derive all required sizes from `assets/whattheflat-logo.png` (one source image → PNG output). Required manifest entries:
|
||||||
|
|
||||||
|
```js
|
||||||
|
icons: [
|
||||||
|
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus `apple-touch-icon.png` (180×180) in `public/` — iOS ignores the manifest and uses this `<link>`. The maskable variant needs safe-zone padding (~10%) so the logo isn't cropped by Android's circular mask.
|
||||||
|
|
||||||
|
One-liner with ImageMagick if available, else export manually from the source PNG:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
magick assets/whattheflat-logo.png -resize 192x192 public/icon-192.png
|
||||||
|
magick assets/whattheflat-logo.png -resize 512x512 public/icon-512.png
|
||||||
|
magick assets/whattheflat-logo.png -resize 180x180 public/apple-touch-icon.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Update `index.html`
|
||||||
|
|
||||||
|
Two problems with the current `<head>`:
|
||||||
|
|
||||||
|
1. **No PWA tags** — add theme-color, manifest link, apple-touch-icon, apple-mobile-web-app-capable, description.
|
||||||
|
2. **CSP is hardcoded for dev** — `connect-src 'self' http://localhost:5173 ws://localhost:5173` and `script-src 'self' 'unsafe-eval'`. The `unsafe-eval` and the localhost entries are Vite dev artifacts; production Vite doesn't need `unsafe-eval`. Keep a CSP (it's good hygiene and the app is genuinely offline) but drop the dev bits:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<meta name="theme-color" content="#0f0f0f" />
|
||||||
|
<meta name="description" content="Real-time key and chord detection for live jams." />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
```
|
||||||
|
|
||||||
|
CSP (production):
|
||||||
|
```
|
||||||
|
default-src 'self';
|
||||||
|
script-src 'self';
|
||||||
|
style-src 'self' 'unsafe-inline';
|
||||||
|
img-src 'self' data:;
|
||||||
|
connect-src 'self';
|
||||||
|
media-src 'self' blob:; <!-- the mic stream lives in a blob -->
|
||||||
|
```
|
||||||
|
`blob:` in `media-src` is required because `getUserMedia` streams are blob-backed and the analyser reads them. Verify nothing else regresses — `vite-plugin-pwa` registers the SW from a same-origin script, so `'self'` covers it.
|
||||||
|
|
||||||
|
Move the dev-only CSP into a Vite conditional so dev still allows HMR/websocket:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// vite.config.js — inject dev CSP via plugin, or keep a separate dev index template.
|
||||||
|
```
|
||||||
|
Simplest: keep one CSP in `index.html` with the production values, and in dev let Vite's own injected tags coexist (HMR works over the same origin websocket; CSP `connect-src 'self'` already allows it). If dev breaks, add `ws://localhost:5173` only in a dev-specific block.
|
||||||
|
|
||||||
|
### 1.5 Verify
|
||||||
|
|
||||||
|
- `npm run build` → `dist/` contains `manifest.webmanifest`, `registerSW.js`, `sw.js`, and all `public/` icons.
|
||||||
|
- Serve `dist/` locally with `npm run preview` and use Chrome DevTools → Application → Manifest (should show icons, installability ✓) and Service Workers (registered).
|
||||||
|
- Install to desktop / "Add to Home Screen" on mobile.
|
||||||
|
- Confirm the app still works **fully offline** after first load (airplane mode). Since there are no network calls, the only risk is the SW failing to precache — Workbox handles this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Firebase Hosting setup
|
||||||
|
|
||||||
|
Goal: serve `dist/` over HTTPS on a `*.web.app` domain (or custom domain).
|
||||||
|
|
||||||
|
### 2.1 Install the CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D firebase-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Init
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx firebase init hosting
|
||||||
|
```
|
||||||
|
|
||||||
|
Answer:
|
||||||
|
- **Public directory:** `dist`
|
||||||
|
- **Single-page app (rewrite all urls to /index.html):** Yes — though the app has no client-side routing today, this protects future routes and is harmless.
|
||||||
|
- **Set up automatic builds with GitHub:** **No.** (You're not using GitHub.)
|
||||||
|
- **File `dist/index.html` already exists — overwrite?** **No.** This is critical — saying yes would clobber the Vite-built index.
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- `firebase.json`
|
||||||
|
- `.firebaserc`
|
||||||
|
|
||||||
|
### 2.3 `firebase.json` (target shape)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hosting": {
|
||||||
|
"public": "dist",
|
||||||
|
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
||||||
|
"rewrites": [{ "source": "**", "destination": "/index.html" }],
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "**/*.@(js|css|svg|png|ico|woff2|webmanifest)",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/sw.js",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "no-cache" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/index.html",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "no-cache" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Why the headers: Vite hashes asset filenames, so JS/CSS/icons are content-addressed → cache them **immortal**. `index.html` and `sw.js` must stay fresh so updates propagate — `no-cache` revalidates them every time. (Note: with `vite-plugin-pwa`'s `autoUpdate`, the SW self-updates; the `no-cache` on `sw.js` is belt-and-braces.)
|
||||||
|
|
||||||
|
### 2.4 First deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npx firebase deploy --only hosting
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `https://<project>.web.app`. The mic will now work because the origin is HTTPS.
|
||||||
|
|
||||||
|
### 2.5 `.gitignore`
|
||||||
|
|
||||||
|
Add `.firebase/` and `firebase-debug.log` (already in a sane gitignore, but confirm).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — (optional) custom domain
|
||||||
|
|
||||||
|
In Firebase console → Hosting → Add custom domain. Add the DNS TXT/CNAME records it gives you. Lets Encrypt cert is auto-provisioned. No app change needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Web optimization checklist
|
||||||
|
|
||||||
|
Things that matter specifically for shipping this app on the web. Most are cheap; do them before the first public deploy.
|
||||||
|
|
||||||
|
### 4.1 Drop the dead dependency
|
||||||
|
|
||||||
|
`audiomotion-analyzer` is listed in `package.json` but **imported nowhere** in `src/`. Vite tree-shakes unused ESM, so it likely doesn't bloat the production bundle — but it still inflates `npm install` and is misleading. Either:
|
||||||
|
- **Remove it** from `dependencies` (one-line `npm uninstall audiomotion-analyzer`), or
|
||||||
|
- If something depends on it being present (it doesn't — grep confirms zero imports), wire it up.
|
||||||
|
|
||||||
|
Recommend: remove.
|
||||||
|
|
||||||
|
### 4.2 Audit bundle size
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npx vite-bundle-visualizer # one-off, no config needed
|
||||||
|
```
|
||||||
|
Expected heavy bits: `pitchy` (small, pure JS) and React 19. `electron` / `electron-builder` are devDeps and excluded from the web build. If the visualizer shows anything surprising (e.g. a transitive dep pulling in moment/lodash), trim it.
|
||||||
|
|
||||||
|
### 4.3 Code-split the heavy, rarely-used views
|
||||||
|
|
||||||
|
`DebugView`, `DrumView`, and `Tuner` are collapsible panels most users never open. They import theory helpers and own audio contexts. Lazy-load them so the initial bundle stays lean:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const Tuner = lazy(() => import('./components/Tuner'))
|
||||||
|
const DebugView = lazy(() => import('./components/DebugView'))
|
||||||
|
const DrumView = lazy(() => import('./components/DrumView'))
|
||||||
|
```
|
||||||
|
Wrap in `<Suspense fallback={null}>`. These are already behind toggle buttons, so there's no UX cost. This is the single biggest web win — the core pitch/chord flow loads first.
|
||||||
|
|
||||||
|
### 4.4 Audio permissions & UX on the web
|
||||||
|
|
||||||
|
- **HTTPS** — covered by Firebase. `getUserMedia` is blocked on plain HTTP (except `localhost`).
|
||||||
|
- **User gesture** — the existing "Start Listening" button already provides the gesture the browser requires to call `getUserMedia`. Don't change this; never auto-start listening on load.
|
||||||
|
- **Mobile Safari quirks** — iOS Safari:
|
||||||
|
- Requires `audio: { echoCancellation: false }` style may differ; test the existing `AudioCapture` constraints on an iPhone.
|
||||||
|
- AudioContext must be resumed after a gesture — already handled since capture starts on click.
|
||||||
|
- `sharedArrayBuffer` is not needed by this app (no worklets), so no COOP/COEP headers required. Good — don't add them; they'd complicate the Firebase static host.
|
||||||
|
- **Permissions API** — optionally surface "microphone blocked" before the user clicks, using `navigator.permissions.query({ name: 'microphone' })`. The existing `micError` state already covers the denied case; this is a polish step, not required.
|
||||||
|
|
||||||
|
### 4.5 Mobile layout
|
||||||
|
|
||||||
|
The app uses Tailwind with responsive `lg:` breakpoints already (e.g. the progressions sidebar is `hidden lg:block`). For a real mobile deploy:
|
||||||
|
- Test the fretboard / piano SVGs at phone widths — they're SVG so they scale, but text labels may crowd.
|
||||||
|
- The `controls bar` wraps with `flex-wrap` — good. Verify the key-lock select dropdowns are tappable.
|
||||||
|
- Consider a `display: standalone` install prompt; the manifest already sets `standalone`.
|
||||||
|
- This is a "play and look at the screen" app — portrait guitar fretboard orientation may want `orientation: 'portrait'` in the manifest instead of `'any'`. Decide based on the primary instrument view.
|
||||||
|
|
||||||
|
### 4.6 Performance for the audio hot loop
|
||||||
|
|
||||||
|
Already mostly tuned (the dual-analyser design is deliberate). Web-specific notes:
|
||||||
|
- `requestAnimationFrame` driving the analyser is fine on web — no change.
|
||||||
|
- `Float32Array` chroma math is cheap. No change needed.
|
||||||
|
- Don't add a Workbox runtime cache for audio — there's no audio to cache; everything is live.
|
||||||
|
- If you ever add an AudioWorklet, its file must be served with the correct MIME and same-origin — Workbox precaches it as part of `globPatterns` only if it's emitted to `dist/`. Not needed today.
|
||||||
|
|
||||||
|
### 4.7 SEO / social (minimal — this is an app, not content)
|
||||||
|
|
||||||
|
- `<title>` and meta description (added in 1.4) are enough for the web.app URL to have a sane share card.
|
||||||
|
- Add `og:title` / `og:description` / `og:image` to `index.html` if you want a nice link preview (point `og:image` to `/icon-512.png`). Optional.
|
||||||
|
- No sitemap needed for a single-page offline app.
|
||||||
|
|
||||||
|
### 4.8 Keep Electron working
|
||||||
|
|
||||||
|
The desktop build must not regress:
|
||||||
|
- `electron-builder` config in `package.json` is untouched by any of the above.
|
||||||
|
- The CSP change in `index.html` affects both targets — verify the desktop window still loads (it serves the built `dist/index.html`, so the new production CSP applies there too; that's fine and arguably better).
|
||||||
|
- `vite-plugin-pwa` is a dev dependency; it only runs at build time and doesn't touch the renderer runtime in a way that breaks Electron.
|
||||||
|
- Run `npm run electron:dev` after Phase 1 to confirm the Electron path still works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What stays Electron-only
|
||||||
|
|
||||||
|
Nothing in this plan removes or forks code. The Electron and web builds share the exact same `dist/` output. The only per-target differences are:
|
||||||
|
- Electron adds the `electron/main.cjs` shell and `electron-builder` packaging.
|
||||||
|
- Web adds the PWA manifest + SW (ignored by Electron) and is served over HTTPS.
|
||||||
|
|
||||||
|
If a future feature needs a native API (filesystem dialogs, auto-launch, tray), it would go through Electron's preload/IPC — which currently exposes nothing. At that point you'd add an `isElectron` guard and a `preload` IPC bridge. Not needed for any current feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order of operations
|
||||||
|
|
||||||
|
1. **Phase 1.2** — create `public/`, drop in icons (Phase 1.3).
|
||||||
|
2. **Phase 1.1 + 1.4** — add `vite-plugin-pwa`, update `index.html`, relax CSP.
|
||||||
|
3. Verify with `npm run preview` + DevTools (Phase 1.5).
|
||||||
|
4. **Phase 2** — `firebase init hosting`, hand-tune `firebase.json`, first deploy.
|
||||||
|
5. **Phase 4.1** — remove dead `audiomotion-analyzer` dep.
|
||||||
|
6. **Phase 4.3** — lazy-load the three collapsible views.
|
||||||
|
7. Polish: mobile pass (4.5), bundle audit (4.2), optional OG tags (4.7).
|
||||||
|
|
||||||
|
Each step is independently revertible. Stop after step 4 and you have a working, installable, offline PWA on the web; the rest is optimization.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
const { app, BrowserWindow, systemPreferences } = require('electron')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const isDev = !app.isPackaged
|
||||||
|
|
||||||
|
async function handlePermissions() {
|
||||||
|
// macOS requires an explicit native request for microphone access in packaged apps
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
try {
|
||||||
|
const status = systemPreferences.getMediaAccessStatus('microphone')
|
||||||
|
if (status !== 'granted') {
|
||||||
|
await systemPreferences.askForMediaAccess('microphone')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Main] Microphone permission error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 620,
|
||||||
|
minHeight: 600,
|
||||||
|
title: 'WhatTheFlat ♭? - JamBuddy',
|
||||||
|
icon: path.join(__dirname, '../assets/whattheflat-logo.png'),
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false, // allows renderer getUserMedia to work on all platforms
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
win.loadURL('http://localhost:5173')
|
||||||
|
win.webContents.openDevTools()
|
||||||
|
} else {
|
||||||
|
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
await handlePermissions()
|
||||||
|
createWindow()
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Preload runs in a privileged context before the renderer.
|
||||||
|
// Expose only what the app actually needs from Node/Electron here.
|
||||||
|
// Currently the app is pure browser JS so nothing needs exposing.
|
||||||
|
const { contextBridge } = require('electron')
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
platform: process.platform,
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"hosting": {
|
||||||
|
"site": "jambuddy",
|
||||||
|
"public": "dist",
|
||||||
|
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
||||||
|
"rewrites": [{ "source": "**", "destination": "/index.html" }],
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "**/*.@(js|css|svg|png|ico|woff2|webmanifest)",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/sw.js",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "no-cache" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/index.html",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cache-Control", "value": "no-cache" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>WhatTheFlat</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "whattheflat-frontend",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"pitchy": "^4.1.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.3.1",
|
|
||||||
"@types/react-dom": "^18.3.1",
|
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"postcss": "^8.4.47",
|
|
||||||
"tailwindcss": "^3.4.14",
|
|
||||||
"vite": "^5.4.10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
||||||
import AudioCapture from './components/AudioCapture'
|
|
||||||
import ProgressionBanner from './components/ProgressionBanner'
|
|
||||||
import KeyDisplay from './components/KeyDisplay'
|
|
||||||
import ChordDisplay from './components/ChordDisplay'
|
|
||||||
import SafeNotes from './components/SafeNotes'
|
|
||||||
import Fretboard from './components/Fretboard'
|
|
||||||
import ProgressionSuggestions from './components/ProgressionSuggestions'
|
|
||||||
import ChatAssistant from './components/ChatAssistant'
|
|
||||||
import { NOTES, detectKey, matchChordFromChroma, detectRepeatingProgression } from './lib/theory'
|
|
||||||
|
|
||||||
// Key detection tuning
|
|
||||||
const NOTE_HISTORY_SIZE = 80
|
|
||||||
const KEY_VOTE_WINDOW = 12
|
|
||||||
const KEY_VOTE_THRESHOLD = 9 // out of 12 — very stable
|
|
||||||
|
|
||||||
// Chord detection tuning
|
|
||||||
const CHROMA_SMOOTH = 12 // frames to average (~200ms at 60fps)
|
|
||||||
const CHORD_VOTE_THRESHOLD = 3 // consecutive identical detections required
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
// ── Listening state ──────────────────────────────────────────────────────
|
|
||||||
const [isListening, setIsListening] = useState(false)
|
|
||||||
|
|
||||||
// ── App mode ─────────────────────────────────────────────────────────────
|
|
||||||
const [appMode, setAppMode] = useState('beginner') // 'beginner' | 'advanced'
|
|
||||||
|
|
||||||
// ── Key: auto-detected + optional lock ───────────────────────────────────
|
|
||||||
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
|
|
||||||
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
|
|
||||||
const [lockRoot, setLockRoot] = useState('A')
|
|
||||||
const [lockMode, setLockMode] = useState('minor')
|
|
||||||
|
|
||||||
// Effective key used by all components
|
|
||||||
const effectiveKey = lockedKey ?? keyInfo
|
|
||||||
// Locked key = only match diatonic chords regardless of mode — far fewer candidates
|
|
||||||
const isStrictMode = lockedKey !== null
|
|
||||||
|
|
||||||
// ── Chord state ───────────────────────────────────────────────────────────
|
|
||||||
const [chordHistory, setChordHistory] = useState([])
|
|
||||||
const [detectedProgression, setDetectedProgression] = useState(null)
|
|
||||||
|
|
||||||
// ── Internal refs ─────────────────────────────────────────────────────────
|
|
||||||
const noteHistoryRef = useRef([])
|
|
||||||
const keyVotesRef = useRef([])
|
|
||||||
const effectiveKeyRef = useRef(null) // mirror for use inside callbacks
|
|
||||||
const chromaRingRef = useRef(
|
|
||||||
Array.from({ length: CHROMA_SMOOTH }, () => new Float32Array(12))
|
|
||||||
)
|
|
||||||
const chromaIdxRef = useRef(0)
|
|
||||||
const chordVotesRef = useRef([])
|
|
||||||
|
|
||||||
// Keep ref in sync
|
|
||||||
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
|
|
||||||
|
|
||||||
// ── Detect progression whenever chord history changes ─────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
setDetectedProgression(detectRepeatingProgression(chordHistory))
|
|
||||||
}, [chordHistory])
|
|
||||||
|
|
||||||
// ── Key lock handlers ─────────────────────────────────────────────────────
|
|
||||||
function applyLock() {
|
|
||||||
const info = { root: lockRoot, mode: lockMode, confidence: 1 }
|
|
||||||
setLockedKey(info)
|
|
||||||
effectiveKeyRef.current = info
|
|
||||||
chordVotesRef.current = []
|
|
||||||
setChordHistory([])
|
|
||||||
setDetectedProgression(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeLock() {
|
|
||||||
setLockedKey(null)
|
|
||||||
effectiveKeyRef.current = keyInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Note handler: drives key detection (pitch-based) ──────────────────────
|
|
||||||
const handleNote = useCallback(({ pitchClass }) => {
|
|
||||||
const history = noteHistoryRef.current
|
|
||||||
history.push(pitchClass)
|
|
||||||
if (history.length > NOTE_HISTORY_SIZE) history.shift()
|
|
||||||
if (history.length < 10) return
|
|
||||||
if (history.length % 5 !== 0) return
|
|
||||||
|
|
||||||
const result = detectKey(history)
|
|
||||||
if (result.confidence < 0.5) return
|
|
||||||
|
|
||||||
const votes = keyVotesRef.current
|
|
||||||
votes.push(`${result.root}_${result.mode}`)
|
|
||||||
if (votes.length > KEY_VOTE_WINDOW) votes.shift()
|
|
||||||
|
|
||||||
const counts = {}
|
|
||||||
for (const v of votes) counts[v] = (counts[v] || 0) + 1
|
|
||||||
const [winner, count] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]
|
|
||||||
|
|
||||||
if (count >= KEY_VOTE_THRESHOLD) {
|
|
||||||
const [root, mode] = winner.split('_')
|
|
||||||
setKeyInfo(prev => {
|
|
||||||
if (prev?.root === root && prev?.mode === mode) {
|
|
||||||
return { root, mode, confidence: result.confidence }
|
|
||||||
}
|
|
||||||
// Key changed — reset chord votes but keep history visible
|
|
||||||
if (!lockedKey) {
|
|
||||||
chordVotesRef.current = []
|
|
||||||
}
|
|
||||||
return { root, mode, confidence: result.confidence }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [lockedKey])
|
|
||||||
|
|
||||||
// ── Chroma handler: drives chord detection ────────────────────────────────
|
|
||||||
const handleChroma = useCallback((chroma, bassPC) => {
|
|
||||||
const ring = chromaRingRef.current
|
|
||||||
ring[chromaIdxRef.current % CHROMA_SMOOTH] = chroma
|
|
||||||
chromaIdxRef.current++
|
|
||||||
if (chromaIdxRef.current % CHROMA_SMOOTH !== 0) return
|
|
||||||
|
|
||||||
const key = effectiveKeyRef.current
|
|
||||||
if (!key) return
|
|
||||||
|
|
||||||
// Average ring buffer
|
|
||||||
const avg = new Float32Array(12)
|
|
||||||
for (const frame of ring) for (let i = 0; i < 12; i++) avg[i] += frame[i]
|
|
||||||
for (let i = 0; i < 12; i++) avg[i] /= CHROMA_SMOOTH
|
|
||||||
|
|
||||||
const chord = matchChordFromChroma(avg, key, bassPC, isStrictMode)
|
|
||||||
if (!chord) {
|
|
||||||
// Ambiguous moment (transition, silence) — reset streak, history is untouched
|
|
||||||
chordVotesRef.current = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const votes = chordVotesRef.current
|
|
||||||
votes.push(chord)
|
|
||||||
if (votes.length > CHORD_VOTE_THRESHOLD) votes.shift()
|
|
||||||
|
|
||||||
// All last N detections must agree — one wrong reading resets the streak
|
|
||||||
if (votes.length >= CHORD_VOTE_THRESHOLD && votes.every(v => v === votes[0])) {
|
|
||||||
const winner = votes[0]
|
|
||||||
setChordHistory(prev => {
|
|
||||||
if (prev[prev.length - 1] === winner) return prev
|
|
||||||
return [...prev.slice(-30), winner]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [isStrictMode])
|
|
||||||
|
|
||||||
const currentChord = chordHistory[chordHistory.length - 1]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-surface text-white p-4 md:p-6">
|
|
||||||
|
|
||||||
{/* ── Header ── */}
|
|
||||||
<header className="mb-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-accent">
|
|
||||||
WhatTheFlat <span className="text-gray-600">♭?</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-xs text-gray-600 mt-0.5">Real-time key detection for real humans</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsListening(l => !l)}
|
|
||||||
className={`px-5 py-2.5 rounded-full font-semibold text-sm transition-all ${
|
|
||||||
isListening
|
|
||||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
|
||||||
: 'bg-accent hover:bg-purple-600 text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isListening ? 'Stop' : 'Start Listening'}
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ── Controls bar ── */}
|
|
||||||
<div className="mb-4 flex flex-wrap gap-3 items-center p-3 bg-panel border border-border rounded-xl">
|
|
||||||
{/* Mode toggle */}
|
|
||||||
<div className="flex bg-surface border border-border rounded-full p-0.5 text-sm">
|
|
||||||
{['beginner', 'advanced'].map(m => (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
onClick={() => setAppMode(m)}
|
|
||||||
className={`px-4 py-1 rounded-full capitalize transition-all ${
|
|
||||||
appMode === m ? 'bg-accent text-white' : 'text-gray-400 hover:text-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key lock */}
|
|
||||||
{lockedKey ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="px-3 py-1 bg-accent/20 border border-accent text-accent rounded-full text-sm font-semibold">
|
|
||||||
🔒 {lockedKey.root} {lockedKey.mode}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={removeLock}
|
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 underline"
|
|
||||||
>
|
|
||||||
unlock
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<select
|
|
||||||
value={lockRoot}
|
|
||||||
onChange={e => setLockRoot(e.target.value)}
|
|
||||||
className="bg-surface border border-border rounded-lg px-2 py-1 text-sm text-gray-300"
|
|
||||||
>
|
|
||||||
{NOTES.map(n => <option key={n}>{n}</option>)}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={lockMode}
|
|
||||||
onChange={e => setLockMode(e.target.value)}
|
|
||||||
className="bg-surface border border-border rounded-lg px-2 py-1 text-sm text-gray-300"
|
|
||||||
>
|
|
||||||
<option value="major">Major</option>
|
|
||||||
<option value="minor">Minor</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={applyLock}
|
|
||||||
className="px-3 py-1 bg-border hover:bg-accent/20 border border-border hover:border-accent text-sm rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
Lock Key
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Auto-detected key badge (advanced mode) */}
|
|
||||||
{appMode === 'advanced' && keyInfo && !lockedKey && (
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
auto: {keyInfo.root} {keyInfo.mode} ({Math.round(keyInfo.confidence * 100)}%)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AudioCapture onNote={handleNote} onChroma={handleChroma} isListening={isListening} />
|
|
||||||
|
|
||||||
{/* ── Progression banner — full width ── */}
|
|
||||||
<ProgressionBanner
|
|
||||||
chordHistory={chordHistory}
|
|
||||||
keyInfo={effectiveKey}
|
|
||||||
detectedProgression={detectedProgression}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── Main grid ── */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<KeyDisplay keyInfo={effectiveKey} locked={!!lockedKey} />
|
|
||||||
<ChordDisplay history={chordHistory} />
|
|
||||||
<SafeNotes keyInfo={effectiveKey} currentChord={currentChord} />
|
|
||||||
<ProgressionSuggestions keyInfo={effectiveKey} />
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<Fretboard
|
|
||||||
keyInfo={effectiveKey}
|
|
||||||
currentChord={currentChord}
|
|
||||||
pentatonicOnly={appMode === 'beginner'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<ChatAssistant keyInfo={effectiveKey} currentChord={currentChord} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { PitchDetector } from 'pitchy'
|
|
||||||
import { NOTES } from '../lib/theory'
|
|
||||||
|
|
||||||
// ─── Why two analysers? ───────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// The Web Audio FFT has linearly-spaced bins: bin width = sampleRate / fftSize.
|
|
||||||
//
|
|
||||||
// fftSize 4096 → ~10.8 Hz/bin (default we were using)
|
|
||||||
// fftSize 16384 → ~2.7 Hz/bin (multi-rate chord analyser)
|
|
||||||
//
|
|
||||||
// On the low guitar strings the gap between adjacent semitones is only ~5-6 Hz.
|
|
||||||
// At 10.8 Hz/bin we literally cannot separate A2 (110 Hz) from A#2 (116 Hz).
|
|
||||||
// That is the single biggest source of wrong chord notes on the low strings.
|
|
||||||
//
|
|
||||||
// Solution: run a second, larger analyser just for chord/chroma detection.
|
|
||||||
// The pitch analyser stays small (4096) so pitchy has a 90ms window — fast
|
|
||||||
// enough for responsive pitch detection. The chord analyser uses 16384 (~370ms
|
|
||||||
// window) — slower to respond but with 2.7 Hz bins that can cleanly separate
|
|
||||||
// every semitone across the guitar's entire range.
|
|
||||||
//
|
|
||||||
// This is an approximation of the Constant-Q Transform (CQT) your friend
|
|
||||||
// mentioned: CQT achieves log-spaced bins mathematically; we approximate it
|
|
||||||
// by simply using a much larger FFT window.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const PITCH_FFT = 4096 // ~90ms window — good temporal resolution for pitch
|
|
||||||
const CHORD_FFT = 16384 // ~370ms window — 2.7 Hz/bin, separates low semitones
|
|
||||||
const MIN_CLARITY = 0.80
|
|
||||||
const MIN_VOLUME = 0.01
|
|
||||||
const NOISE_FLOOR = -65 // dB
|
|
||||||
|
|
||||||
// ─── Harmonic summation chroma ────────────────────────────────────────────────
|
|
||||||
// Each FFT bin votes back toward lower fundamentals that could have generated
|
|
||||||
// it as an overtone. This undoes the harmonic contamination that makes minor
|
|
||||||
// chords look like major ones (the 5th harmonic of the root lands on the major
|
|
||||||
// 3rd, which is NOT in the minor chord).
|
|
||||||
const HARMONIC_WEIGHTS = [1.0, 0.5, 0.33, 0.25, 0.2] // h = 1…5
|
|
||||||
|
|
||||||
function computeChroma(freqData, sampleRate, fftSize) {
|
|
||||||
const chroma = new Float32Array(12)
|
|
||||||
const binHz = sampleRate / fftSize
|
|
||||||
const N = freqData.length
|
|
||||||
|
|
||||||
for (let bin = 2; bin < N; bin++) {
|
|
||||||
const freq = bin * binHz
|
|
||||||
if (freq < 80 || freq > 4000) continue
|
|
||||||
const db = freqData[bin]
|
|
||||||
if (db < NOISE_FLOOR) continue
|
|
||||||
|
|
||||||
const amp = Math.sqrt(Math.pow(10, db / 10)) // amplitude, not power
|
|
||||||
|
|
||||||
for (let h = 1; h <= HARMONIC_WEIGHTS.length; h++) {
|
|
||||||
const fundamental = freq / h
|
|
||||||
if (fundamental < 40 || fundamental > 2000) continue
|
|
||||||
const midi = 12 * Math.log2(fundamental / 440) + 69
|
|
||||||
const pc = ((Math.round(midi) % 12) + 12) % 12
|
|
||||||
chroma[pc] += amp * HARMONIC_WEIGHTS[h - 1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < 12; i++) chroma[i] = Math.log1p(chroma[i])
|
|
||||||
const max = Math.max(...chroma)
|
|
||||||
if (max > 0) for (let i = 0; i < 12; i++) chroma[i] /= max
|
|
||||||
return chroma
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectBassPC(freqData, sampleRate, fftSize) {
|
|
||||||
const binHz = sampleRate / fftSize
|
|
||||||
let maxPower = 0, bestMidi = -1
|
|
||||||
for (let bin = 2; bin < freqData.length; bin++) {
|
|
||||||
const freq = bin * binHz
|
|
||||||
if (freq < 40 || freq > 350) continue
|
|
||||||
const db = freqData[bin]
|
|
||||||
if (db < NOISE_FLOOR) continue
|
|
||||||
const power = Math.pow(10, db / 10)
|
|
||||||
if (power > maxPower) {
|
|
||||||
maxPower = power
|
|
||||||
bestMidi = Math.round(12 * Math.log2(freq / 440) + 69)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bestMidi < 0) return null
|
|
||||||
return ((bestMidi % 12) + 12) % 12
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AudioCapture({ onNote, onChroma, isListening }) {
|
|
||||||
const audioCtxRef = useRef(null)
|
|
||||||
const pitchAnalyser = useRef(null)
|
|
||||||
const chordAnalyser = useRef(null)
|
|
||||||
const timeBufRef = useRef(null)
|
|
||||||
const freqBufRef = useRef(null)
|
|
||||||
const detectorRef = useRef(null)
|
|
||||||
const rafRef = useRef(null)
|
|
||||||
const streamRef = useRef(null)
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
|
||||||
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop())
|
|
||||||
if (audioCtxRef.current) audioCtxRef.current.close()
|
|
||||||
audioCtxRef.current = null
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
|
||||||
stop()
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
||||||
streamRef.current = stream
|
|
||||||
|
|
||||||
const ctx = new AudioContext()
|
|
||||||
audioCtxRef.current = ctx
|
|
||||||
const source = ctx.createMediaStreamSource(stream)
|
|
||||||
|
|
||||||
// Small analyser — pitch detection needs fast time-domain data
|
|
||||||
const pa = ctx.createAnalyser()
|
|
||||||
pa.fftSize = PITCH_FFT
|
|
||||||
pa.smoothingTimeConstant = 0.0 // no smoothing: pitchy needs clean waveform
|
|
||||||
pitchAnalyser.current = pa
|
|
||||||
source.connect(pa)
|
|
||||||
timeBufRef.current = new Float32Array(pa.fftSize)
|
|
||||||
detectorRef.current = PitchDetector.forFloat32Array(pa.fftSize)
|
|
||||||
|
|
||||||
// Large analyser — chord detection needs fine frequency resolution
|
|
||||||
const ca = ctx.createAnalyser()
|
|
||||||
ca.fftSize = CHORD_FFT
|
|
||||||
ca.smoothingTimeConstant = 0.65 // smooth over time for stable chord reading
|
|
||||||
chordAnalyser.current = ca
|
|
||||||
source.connect(ca)
|
|
||||||
freqBufRef.current = new Float32Array(ca.frequencyBinCount)
|
|
||||||
|
|
||||||
function tick() {
|
|
||||||
const timeBuf = timeBufRef.current
|
|
||||||
pa.getFloatTimeDomainData(timeBuf)
|
|
||||||
|
|
||||||
const rms = Math.sqrt(timeBuf.reduce((s, v) => s + v * v, 0) / timeBuf.length)
|
|
||||||
if (rms >= MIN_VOLUME) {
|
|
||||||
// Pitch via McLeod (autocorrelation) — unaffected by FFT bin size
|
|
||||||
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
|
|
||||||
if (clarity >= MIN_CLARITY && freq > 60 && freq < 4200) {
|
|
||||||
const midi = Math.round(12 * Math.log2(freq / 440) + 69)
|
|
||||||
const pitchClass = ((midi % 12) + 12) % 12
|
|
||||||
onNote({ noteName: NOTES[pitchClass], pitchClass, freq, midi, clarity })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chord chroma from the high-resolution FFT
|
|
||||||
if (onChroma) {
|
|
||||||
const freqBuf = freqBufRef.current
|
|
||||||
ca.getFloatFrequencyData(freqBuf)
|
|
||||||
onChroma(
|
|
||||||
computeChroma(freqBuf, ctx.sampleRate, ca.fftSize),
|
|
||||||
detectBassPC(freqBuf, ctx.sampleRate, ca.fftSize)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
tick()
|
|
||||||
}, [onNote, onChroma, stop])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isListening) start().catch(console.error)
|
|
||||||
else stop()
|
|
||||||
return stop
|
|
||||||
}, [isListening, start, stop])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
|
||||||
|
|
||||||
export default function ChatAssistant({ keyInfo, currentChord }) {
|
|
||||||
const [messages, setMessages] = useState([])
|
|
||||||
const [input, setInput] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const bottomRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
}, [messages])
|
|
||||||
|
|
||||||
async function send(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!input.trim() || loading) return
|
|
||||||
|
|
||||||
const userMsg = { role: 'user', content: input.trim() }
|
|
||||||
const next = [...messages, userMsg]
|
|
||||||
setMessages(next)
|
|
||||||
setInput('')
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const context = {}
|
|
||||||
if (keyInfo?.root) context.key = `${keyInfo.root} ${keyInfo.mode}`
|
|
||||||
if (currentChord) context.chord = currentChord
|
|
||||||
|
|
||||||
const res = await fetch('/api/chat', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ messages: next, context }),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
setMessages(prev => [...prev, { role: 'assistant', content: data.reply }])
|
|
||||||
} catch (err) {
|
|
||||||
setMessages(prev => [...prev, {
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'Could not reach the AI assistant. Is the backend running?',
|
|
||||||
}])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-panel border border-border rounded-2xl p-6 flex flex-col h-80">
|
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-3">Theory Assistant</p>
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-3 pr-1">
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<p className="text-gray-600 text-sm">Ask anything: "What lick works over this chord?" or "Why does the IV sound so resolved?"</p>
|
|
||||||
)}
|
|
||||||
{messages.map((m, i) => (
|
|
||||||
<div key={i} className={`text-sm ${m.role === 'user' ? 'text-right' : 'text-left'}`}>
|
|
||||||
<span className={`inline-block px-3 py-2 rounded-xl max-w-[85%] ${
|
|
||||||
m.role === 'user'
|
|
||||||
? 'bg-accent text-white'
|
|
||||||
: 'bg-border text-gray-200'
|
|
||||||
}`}>
|
|
||||||
{m.content}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{loading && (
|
|
||||||
<div className="text-left">
|
|
||||||
<span className="inline-block px-3 py-2 rounded-xl bg-border text-gray-400 text-sm animate-pulse">
|
|
||||||
Thinking…
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={bottomRef} />
|
|
||||||
</div>
|
|
||||||
<form onSubmit={send} className="mt-3 flex gap-2">
|
|
||||||
<input
|
|
||||||
className="flex-1 bg-surface border border-border rounded-lg px-3 py-2 text-sm outline-none focus:border-accent"
|
|
||||||
placeholder="Ask about music theory…"
|
|
||||||
value={input}
|
|
||||||
onChange={e => setInput(e.target.value)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || !input.trim()}
|
|
||||||
className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium disabled:opacity-40"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export default function ChordDisplay({ history }) {
|
|
||||||
const current = history[history.length - 1]
|
|
||||||
const past = history.slice(-8, -1)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-3">Chord</p>
|
|
||||||
<p className="text-5xl font-bold text-amber-400">
|
|
||||||
{current ?? '—'}
|
|
||||||
</p>
|
|
||||||
{past.length > 0 && (
|
|
||||||
<div className="mt-4 flex gap-2 flex-wrap">
|
|
||||||
{past.map((chord, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="text-sm px-2 py-1 bg-border rounded text-gray-400"
|
|
||||||
>
|
|
||||||
{chord}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
export default function KeyDisplay({ keyInfo, locked = false }) {
|
|
||||||
const { root, mode, confidence } = keyInfo ?? {}
|
|
||||||
const pct = confidence ? Math.round(confidence * 100) : 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-panel border border-border rounded-2xl p-6 text-center">
|
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-1">
|
|
||||||
{locked ? '🔒 Key (locked)' : 'Detected Key'}
|
|
||||||
</p>
|
|
||||||
{root ? (
|
|
||||||
<>
|
|
||||||
<p className="text-6xl font-bold text-accent leading-none">
|
|
||||||
{root}
|
|
||||||
<span className="text-3xl text-gray-400 ml-2">{mode}</span>
|
|
||||||
</p>
|
|
||||||
{!locked && (
|
|
||||||
<div className="mt-3 flex items-center justify-center gap-2">
|
|
||||||
<div className="h-1.5 w-32 bg-border rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-accent rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-500">{pct}% confident</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-2xl text-gray-600 mt-2">Listening…</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
|
||||||
import { toRomanNumeral } from '../lib/theory'
|
|
||||||
|
|
||||||
const HISTORY_SHOWN = 8 // ~2 bars at 4 chords/bar
|
|
||||||
|
|
||||||
function findLoopPosition(chordHistory, progression) {
|
|
||||||
if (!progression?.length || !chordHistory.length) return -1
|
|
||||||
const last = chordHistory[chordHistory.length - 1]
|
|
||||||
for (let p = progression.length - 1; p >= 0; p--) {
|
|
||||||
if (progression[p] !== last) continue
|
|
||||||
let match = true
|
|
||||||
for (let i = 1; i < Math.min(p + 1, chordHistory.length); i++) {
|
|
||||||
if (progression[p - i] !== chordHistory[chordHistory.length - 1 - i]) { match = false; break }
|
|
||||||
}
|
|
||||||
if (match) return p
|
|
||||||
}
|
|
||||||
return progression.indexOf(last)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression }) {
|
|
||||||
const { root, mode } = keyInfo ?? {}
|
|
||||||
|
|
||||||
// Newest chord is the last entry; we show the most recent HISTORY_SHOWN
|
|
||||||
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
|
||||||
const current = visible[visible.length - 1]
|
|
||||||
|
|
||||||
// Animate the current chord slot when it changes
|
|
||||||
const currentRef = useRef(null)
|
|
||||||
const prevChord = useRef(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (current && current !== prevChord.current && currentRef.current) {
|
|
||||||
currentRef.current.animate(
|
|
||||||
[{ opacity: 0, transform: 'scale(0.85)' },
|
|
||||||
{ opacity: 1, transform: 'scale(1)' }],
|
|
||||||
{ duration: 200, easing: 'ease-out', fill: 'forwards' }
|
|
||||||
)
|
|
||||||
prevChord.current = current
|
|
||||||
}
|
|
||||||
}, [current])
|
|
||||||
|
|
||||||
const loopPos = findLoopPosition(chordHistory, detectedProgression)
|
|
||||||
|
|
||||||
if (!chordHistory.length) {
|
|
||||||
return (
|
|
||||||
<div className="bg-panel border border-border rounded-2xl p-5 mb-4 flex items-center justify-center h-28">
|
|
||||||
<p className="text-gray-600">Start listening to detect chords…</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-panel border border-border rounded-2xl p-5 mb-4">
|
|
||||||
|
|
||||||
{/* ── Chord history strip: all HISTORY_SHOWN chords at consistent size ── */}
|
|
||||||
<div className="flex items-stretch gap-1 overflow-x-auto pb-1">
|
|
||||||
{visible.map((chord, i) => {
|
|
||||||
const isCurrent = i === visible.length - 1
|
|
||||||
const age = visible.length - 1 - i // 0 = current, higher = older
|
|
||||||
const opacity = Math.max(0.2, 1 - age * 0.1) // fade but stay readable
|
|
||||||
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
ref={isCurrent ? currentRef : null}
|
|
||||||
style={{ opacity }}
|
|
||||||
className={`
|
|
||||||
flex flex-col items-center justify-end shrink-0 px-3 py-2 rounded-xl
|
|
||||||
transition-colors duration-200
|
|
||||||
${isCurrent
|
|
||||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20'
|
|
||||||
: 'border border-transparent'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<span className={`font-black leading-none tracking-tight ${
|
|
||||||
isCurrent ? 'text-5xl text-accent' : 'text-3xl text-gray-200'
|
|
||||||
}`}>
|
|
||||||
{chord}
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs font-semibold mt-1 ${
|
|
||||||
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
|
||||||
}`}>
|
|
||||||
{rn || '\u00A0'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Detected loop ── */}
|
|
||||||
{detectedProgression && (
|
|
||||||
<div className="mt-4 pt-3 border-t border-border">
|
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-widest mb-2">♻ Detected loop</p>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{detectedProgression.map((chord, i) => {
|
|
||||||
const isActive = i === loopPos
|
|
||||||
const rn = root ? toRomanNumeral(chord, root, mode) : chord
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`flex flex-col items-center px-4 py-2 rounded-xl border transition-all duration-200 ${
|
|
||||||
isActive
|
|
||||||
? 'bg-accent/20 border-accent shadow-[0_0_14px_rgba(168,85,247,0.35)]'
|
|
||||||
: 'bg-border border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className={`text-2xl font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-200'}`}>
|
|
||||||
{chord}
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs mt-1 font-semibold ${isActive ? 'text-amber-400' : 'text-gray-500'}`}>
|
|
||||||
{rn}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<span className="self-center text-gray-600 text-sm pl-1">→ loop</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { getSuggestedProgressions } from '../lib/theory'
|
|
||||||
|
|
||||||
export default function ProgressionSuggestions({ keyInfo }) {
|
|
||||||
const { root, mode } = keyInfo ?? {}
|
|
||||||
|
|
||||||
if (!root) return null
|
|
||||||
|
|
||||||
const progressions = getSuggestedProgressions(root, mode)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
|
||||||
Progressions in {root} {mode}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{progressions.map(prog => (
|
|
||||||
<div key={prog.genre} className="flex items-center gap-3">
|
|
||||||
<span className="text-xs text-gray-500 w-10 shrink-0">{prog.genre}</span>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{prog.chords.map((chord, i) => (
|
|
||||||
<span key={i} className="px-3 py-1 bg-border rounded text-sm font-medium">
|
|
||||||
{chord}
|
|
||||||
<span className="ml-1 text-gray-600 text-xs">({prog.rn[i]})</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { getPentatonicScale, getFullScale, getChordTones } from '../lib/theory'
|
|
||||||
|
|
||||||
const ALL_NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
|
||||||
|
|
||||||
export default function SafeNotes({ keyInfo, currentChord }) {
|
|
||||||
const { root, mode } = keyInfo ?? {}
|
|
||||||
|
|
||||||
if (!root) return null
|
|
||||||
|
|
||||||
const penta = getPentatonicScale(root, mode)
|
|
||||||
const full = getFullScale(root, mode)
|
|
||||||
const chordTones = currentChord ? getChordTones(currentChord) : []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">Safe Notes</p>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{ALL_NOTES.map(note => {
|
|
||||||
const isChordTone = chordTones.includes(note)
|
|
||||||
const isPenta = penta.includes(note)
|
|
||||||
const isScale = full.includes(note)
|
|
||||||
|
|
||||||
let cls = 'px-3 py-2 rounded-lg text-sm font-semibold border transition-all '
|
|
||||||
if (isChordTone) {
|
|
||||||
cls += 'bg-accent text-white border-accent scale-105'
|
|
||||||
} else if (isPenta) {
|
|
||||||
cls += 'bg-accent/20 text-accent border-accent/40'
|
|
||||||
} else if (isScale) {
|
|
||||||
cls += 'bg-border text-gray-300 border-border'
|
|
||||||
} else {
|
|
||||||
cls += 'bg-transparent text-gray-700 border-transparent'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span key={note} className={cls}>{note}</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex gap-4 text-xs text-gray-500">
|
|
||||||
<span><span className="text-accent">■</span> Chord tone</span>
|
|
||||||
<span><span className="text-accent/60">■</span> Pentatonic</span>
|
|
||||||
<span><span className="text-gray-500">■</span> Scale</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #0f0f0f;
|
|
||||||
color: #f5f5f5;
|
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const NOTES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
|
|
||||||
export const NOTES_FLAT = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B']
|
|
||||||
|
|
||||||
// Semitone intervals for each scale mode
|
|
||||||
export const SCALES = {
|
|
||||||
major: [0, 2, 4, 5, 7, 9, 11],
|
|
||||||
minor: [0, 2, 3, 5, 7, 8, 10],
|
|
||||||
dorian: [0, 2, 3, 5, 7, 9, 10],
|
|
||||||
phrygian: [0, 1, 3, 5, 7, 8, 10],
|
|
||||||
lydian: [0, 2, 4, 6, 7, 9, 11],
|
|
||||||
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
|
||||||
pentatonic_major: [0, 2, 4, 7, 9],
|
|
||||||
pentatonic_minor: [0, 3, 5, 7, 10],
|
|
||||||
blues: [0, 3, 5, 6, 7, 10],
|
|
||||||
diminished: [0, 2, 3, 5, 6, 8, 9, 11],
|
|
||||||
whole_tone: [0, 2, 4, 6, 8, 10],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Human-readable scale labels
|
|
||||||
export const SCALE_LABELS = {
|
|
||||||
major: 'Major',
|
|
||||||
minor: 'Natural Minor',
|
|
||||||
dorian: 'Dorian',
|
|
||||||
phrygian: 'Phrygian',
|
|
||||||
lydian: 'Lydian',
|
|
||||||
mixolydian: 'Mixolydian',
|
|
||||||
pentatonic_major: 'Major Pentatonic',
|
|
||||||
pentatonic_minor: 'Minor Pentatonic',
|
|
||||||
blues: 'Blues',
|
|
||||||
diminished: 'Diminished',
|
|
||||||
whole_tone: 'Whole Tone',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Krumhansl-Schmuckler key profiles (only major/minor used for key detection)
|
|
||||||
const KS_MAJOR = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
|
|
||||||
const KS_MINOR = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
|
|
||||||
|
|
||||||
// Chord type definitions: intervals (semitones from root) and display suffix
|
|
||||||
export const CHORD_TYPES = {
|
|
||||||
maj: { intervals: [0, 4, 7], suffix: '' },
|
|
||||||
min: { intervals: [0, 3, 7], suffix: 'm' },
|
|
||||||
dom7: { intervals: [0, 4, 7, 10], suffix: '7' },
|
|
||||||
maj7: { intervals: [0, 4, 7, 11], suffix: 'maj7' },
|
|
||||||
min7: { intervals: [0, 3, 7, 10], suffix: 'm7' },
|
|
||||||
dim: { intervals: [0, 3, 6], suffix: 'dim' },
|
|
||||||
dim7: { intervals: [0, 3, 6, 9], suffix: 'dim7' },
|
|
||||||
half_dim:{ intervals: [0, 3, 6, 10], suffix: 'm7b5' },
|
|
||||||
aug: { intervals: [0, 4, 8], suffix: 'aug' },
|
|
||||||
sus4: { intervals: [0, 5, 7], suffix: 'sus4' },
|
|
||||||
sus2: { intervals: [0, 2, 7], suffix: 'sus2' },
|
|
||||||
maj6: { intervals: [0, 4, 7, 9], suffix: '6' },
|
|
||||||
min6: { intervals: [0, 3, 7, 9], suffix: 'm6' },
|
|
||||||
add9: { intervals: [0, 2, 4, 7], suffix: 'add9' },
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chord types considered during real-time chroma matching
|
|
||||||
const MATCH_CHORD_TYPES = [
|
|
||||||
'maj', 'min', 'dom7', 'min7', 'dim', 'half_dim', 'aug', 'sus4', 'add9',
|
|
||||||
]
|
|
||||||
|
|
||||||
// Minimum score for a chord match to be reported
|
|
||||||
const CHORD_MATCH_MIN_SCORE = 0.42
|
|
||||||
|
|
||||||
// Minimum margin over second-best for a match to be considered unambiguous
|
|
||||||
const CHORD_MATCH_MIN_MARGIN = 0.08
|
|
||||||
|
|
||||||
// Chord quality arrays for each scale degree in major and minor
|
|
||||||
const DEGREE_QUALITIES = {
|
|
||||||
major: ['', 'm', 'm', '', '', 'm', 'dim'],
|
|
||||||
minor: ['m', 'dim','', 'm', 'm', '', '' ],
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROMAN_NUMERALS = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']
|
|
||||||
|
|
||||||
// Common chord progressions by genre, expressed as semitone offsets from the root
|
|
||||||
const PROGRESSIONS = {
|
|
||||||
pop: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] },
|
|
||||||
blues: { name: 'Blues', rn: ['I', 'IV', 'V'], degrees: [0, 5, 7] },
|
|
||||||
folk: { name: 'Folk', rn: ['I', 'IV', 'I', 'V'], degrees: [0, 5, 0, 7] },
|
|
||||||
jazz: { name: 'Jazz', rn: ['ii', 'V', 'I'], degrees: [2, 7, 0] },
|
|
||||||
rock: { name: 'Rock', rn: ['I', 'bVII', 'IV', 'I'], degrees: [0, 10, 5, 0] },
|
|
||||||
'50s': { name: "'50s", rn: ['I', 'vi', 'IV', 'V'], degrees: [0, 9, 5, 7] },
|
|
||||||
flamen: { name: 'Flamenco',rn: ['i', 'bVII', 'bVI', 'V'],degrees:[0, 10, 8, 7] },
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the note name for a given semitone value (0–11, wraps automatically).
|
|
||||||
* @param {number} semitone
|
|
||||||
* @param {boolean} [preferFlat=false]
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function noteName(semitone, preferFlat = false) {
|
|
||||||
const pc = ((semitone % 12) + 12) % 12
|
|
||||||
return preferFlat ? NOTES_FLAT[pc] : NOTES[pc]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pearson correlation between two equal-length numeric arrays.
|
|
||||||
* Returns a value in [-1, 1]. A small epsilon avoids division by zero.
|
|
||||||
* @param {number[]} a
|
|
||||||
* @param {number[]} b
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
function pearsonCorrelation(a, b) {
|
|
||||||
const n = a.length
|
|
||||||
const meanA = a.reduce((s, v) => s + v, 0) / n
|
|
||||||
const meanB = b.reduce((s, v) => s + v, 0) / n
|
|
||||||
let num = 0, denA = 0, denB = 0
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const da = a[i] - meanA
|
|
||||||
const db = b[i] - meanB
|
|
||||||
num += da * db
|
|
||||||
denA += da * da
|
|
||||||
denB += db * db
|
|
||||||
}
|
|
||||||
return num / Math.sqrt(denA * denB + 1e-10)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the semitone offset (0–11) of a note name, or -1 if not found.
|
|
||||||
* Accepts both sharp and flat spellings.
|
|
||||||
* @param {string} note
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
function noteIndex(note) {
|
|
||||||
const idx = NOTES.indexOf(note)
|
|
||||||
if (idx !== -1) return idx
|
|
||||||
return NOTES_FLAT.indexOf(note) // handles Db, Eb, etc.
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Key Detection ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects the most likely musical key from a recent history of played notes.
|
|
||||||
*
|
|
||||||
* Uses the Krumhansl-Schmuckler algorithm: builds a pitch-class frequency
|
|
||||||
* vector and correlates it against major and minor profiles for all 12 roots.
|
|
||||||
*
|
|
||||||
* @param {number[]} noteHistory MIDI note numbers or pitch-class integers (0–11)
|
|
||||||
* @returns {{ root: string, mode: 'major'|'minor', confidence: number }}
|
|
||||||
* confidence is normalised to [0, 1]; values below ~0.5 are unreliable.
|
|
||||||
*/
|
|
||||||
export function detectKey(noteHistory) {
|
|
||||||
if (!noteHistory || noteHistory.length < 4) {
|
|
||||||
return { root: 'C', mode: 'major', confidence: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build pitch-class frequency vector
|
|
||||||
const freq = new Array(12).fill(0)
|
|
||||||
for (const note of noteHistory) {
|
|
||||||
freq[((note % 12) + 12) % 12]++
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = { root: 0, mode: 'major', score: -Infinity }
|
|
||||||
|
|
||||||
for (let root = 0; root < 12; root++) {
|
|
||||||
// Rotate the observed frequencies to align with the profile's C-root
|
|
||||||
const rotated = Array.from({ length: 12 }, (_, i) => freq[(i + root) % 12])
|
|
||||||
|
|
||||||
const scoreMaj = pearsonCorrelation(rotated, KS_MAJOR)
|
|
||||||
const scoreMin = pearsonCorrelation(rotated, KS_MINOR)
|
|
||||||
|
|
||||||
if (scoreMaj > best.score) best = { root, mode: 'major', score: scoreMaj }
|
|
||||||
if (scoreMin > best.score) best = { root, mode: 'minor', score: scoreMin }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map correlation [-1, 1] to a rough confidence in [0, 1]
|
|
||||||
const confidence = Math.max(0, Math.min(1, (best.score + 1) / 2))
|
|
||||||
|
|
||||||
return { root: noteName(best.root), mode: best.mode, confidence }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Scale helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all note names in a given scale.
|
|
||||||
* @param {string} root e.g. 'G', 'F#'
|
|
||||||
* @param {keyof SCALES} mode
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
export function getScale(root, mode) {
|
|
||||||
const rootIdx = noteIndex(root)
|
|
||||||
if (rootIdx === -1) return []
|
|
||||||
const intervals = SCALES[mode] ?? SCALES.major
|
|
||||||
return intervals.map(i => noteName(rootIdx + i))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy aliases kept for backwards compatibility
|
|
||||||
export const getFullScale = (root, mode) => getScale(root, mode)
|
|
||||||
export const getPentatonicScale = (root, mode) =>
|
|
||||||
getScale(root, mode === 'minor' ? 'pentatonic_minor' : 'pentatonic_major')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all scale modes that contain every note in `playedNotes`.
|
|
||||||
* Useful for suggesting compatible scales from a detected chord or melody.
|
|
||||||
* @param {string[]} playedNotes e.g. ['C', 'E', 'G']
|
|
||||||
* @param {string} root
|
|
||||||
* @returns {{ mode: string, label: string, notes: string[] }[]}
|
|
||||||
*/
|
|
||||||
export function getCompatibleScales(playedNotes, root) {
|
|
||||||
const played = new Set(playedNotes)
|
|
||||||
return Object.entries(SCALES)
|
|
||||||
.map(([mode]) => ({ mode, label: SCALE_LABELS[mode] ?? mode, notes: getScale(root, mode) }))
|
|
||||||
.filter(({ notes }) => [...played].every(n => notes.includes(n)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Chord helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a chord name and returns its component note names.
|
|
||||||
* @param {string} chordName e.g. 'Am', 'Gmaj7', 'Fdim', 'Baug'
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
export function getChordTones(chordName) {
|
|
||||||
const match = chordName.match(/^([A-G][b#]?)(.*)$/)
|
|
||||||
if (!match) return []
|
|
||||||
const root = noteIndex(match[1])
|
|
||||||
const suffix = match[2] ?? ''
|
|
||||||
|
|
||||||
const type = Object.values(CHORD_TYPES).find(t => t.suffix === suffix)
|
|
||||||
?? CHORD_TYPES.maj // default to major triad
|
|
||||||
|
|
||||||
return type.intervals.map(i => noteName(root + i))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the diatonic chords (triads) for every scale degree.
|
|
||||||
* @param {string} root
|
|
||||||
* @param {'major'|'minor'} mode
|
|
||||||
* @returns {string[]} e.g. ['C', 'Dm', 'Em', 'F', 'G', 'Am', 'Bdim']
|
|
||||||
*/
|
|
||||||
export function getChordsInKey(root, mode) {
|
|
||||||
const rootIdx = noteIndex(root)
|
|
||||||
if (rootIdx === -1) return []
|
|
||||||
const scale = SCALES[mode] ?? SCALES.major
|
|
||||||
const qualities = DEGREE_QUALITIES[mode] ?? DEGREE_QUALITIES.major
|
|
||||||
|
|
||||||
return scale.map((degree, i) => noteName(rootIdx + degree) + qualities[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Progression suggestions ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns common chord progressions transposed to the given key.
|
|
||||||
*
|
|
||||||
* For non-diatonic degrees (e.g. bVII in rock), the quality falls back to a
|
|
||||||
* major triad rather than silently producing a wrong chord name.
|
|
||||||
*
|
|
||||||
* @param {string} root
|
|
||||||
* @param {'major'|'minor'} mode
|
|
||||||
* @returns {{ genre: string, rn: string[], chords: string[] }[]}
|
|
||||||
*/
|
|
||||||
export function getSuggestedProgressions(root, mode) {
|
|
||||||
const rootIdx = noteIndex(root)
|
|
||||||
if (rootIdx === -1) return []
|
|
||||||
const scale = SCALES[mode] ?? SCALES.major
|
|
||||||
const qualities = DEGREE_QUALITIES[mode] ?? DEGREE_QUALITIES.major
|
|
||||||
|
|
||||||
return Object.values(PROGRESSIONS).map(prog => {
|
|
||||||
const chords = prog.degrees.map(semitones => {
|
|
||||||
const noteIdx = (rootIdx + semitones) % 12
|
|
||||||
const name = noteName(noteIdx)
|
|
||||||
const degreeIdx = scale.indexOf(semitones)
|
|
||||||
// Diatonic degree → use proper quality; chromatic (e.g. bVII) → major triad
|
|
||||||
const quality = degreeIdx >= 0 ? qualities[degreeIdx] : ''
|
|
||||||
return name + quality
|
|
||||||
})
|
|
||||||
return { genre: prog.name, rn: prog.rn, chords }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Chroma-based chord matching ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches the most likely chord from a chroma energy vector.
|
|
||||||
*
|
|
||||||
* The algorithm scores each candidate chord by comparing in-chord vs
|
|
||||||
* out-of-chord energy, with bonuses for bass-note and diatonic alignment.
|
|
||||||
* Returns null when no unambiguous winner is found (e.g. during a transition).
|
|
||||||
*
|
|
||||||
* @param {Float32Array|number[]} chroma 12-element pitch-class energy (0–1)
|
|
||||||
* @param {{ root: string, mode: string }} keyInfo
|
|
||||||
* @param {number|null} [bassPC=null] Pitch class of the detected bass note
|
|
||||||
* @param {boolean} [strictDiatonic=false] Only consider diatonic chords
|
|
||||||
* @param {number} [minScore] Override default minimum match score
|
|
||||||
* @param {number} [minMargin] Override default ambiguity margin
|
|
||||||
* @returns {string|null} Chord name, e.g. 'Am7', or null if ambiguous
|
|
||||||
*/
|
|
||||||
export function matchChordFromChroma(
|
|
||||||
chroma,
|
|
||||||
keyInfo,
|
|
||||||
bassPC = null,
|
|
||||||
strictDiatonic = false,
|
|
||||||
minScore = CHORD_MATCH_MIN_SCORE,
|
|
||||||
minMargin = CHORD_MATCH_MIN_MARGIN,
|
|
||||||
) {
|
|
||||||
if (!keyInfo?.root) return null
|
|
||||||
|
|
||||||
const diatonicSet = new Set(getChordsInKey(keyInfo.root, keyInfo.mode))
|
|
||||||
|
|
||||||
let best = { name: null, score: -Infinity }
|
|
||||||
let secondScore = -Infinity
|
|
||||||
|
|
||||||
for (let r = 0; r < 12; r++) {
|
|
||||||
for (const typeKey of MATCH_CHORD_TYPES) {
|
|
||||||
const type = CHORD_TYPES[typeKey]
|
|
||||||
const chordName = noteName(r) + type.suffix
|
|
||||||
|
|
||||||
if (strictDiatonic && !diatonicSet.has(chordName)) continue
|
|
||||||
|
|
||||||
const tones = new Set(type.intervals.map(i => (r + i) % 12))
|
|
||||||
|
|
||||||
let inEnergy = 0, outEnergy = 0
|
|
||||||
for (let pc = 0; pc < 12; pc++) {
|
|
||||||
if (pc === r) {
|
|
||||||
// Root carries the strongest identity signal — double weight
|
|
||||||
inEnergy += chroma[pc] * 2
|
|
||||||
} else if (tones.has(pc)) {
|
|
||||||
inEnergy += chroma[pc]
|
|
||||||
} else {
|
|
||||||
outEnergy += chroma[pc]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip near-silence
|
|
||||||
if (inEnergy + outEnergy < 0.05) continue
|
|
||||||
|
|
||||||
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5)
|
|
||||||
const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0
|
|
||||||
const diatonicBonus = diatonicSet.has(chordName) ? 0.10 : 0
|
|
||||||
|
|
||||||
const finalScore = coverageScore + bassBonus + diatonicBonus
|
|
||||||
|
|
||||||
if (finalScore > best.score) {
|
|
||||||
secondScore = best.score
|
|
||||||
best = { name: chordName, score: finalScore }
|
|
||||||
} else if (finalScore > secondScore) {
|
|
||||||
secondScore = finalScore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearWinner = best.score >= minScore && (best.score - secondScore) >= minMargin
|
|
||||||
return clearWinner ? best.name : null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Roman numeral notation ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a chord name to its Roman numeral relative to a key.
|
|
||||||
*
|
|
||||||
* Non-diatonic (borrowed/chromatic) chords are returned with a flat prefix,
|
|
||||||
* e.g. 'Bb' in C major → '♭VII'. Previously this always returned '♭I'.
|
|
||||||
*
|
|
||||||
* @param {string} chordName e.g. 'Am', 'G7'
|
|
||||||
* @param {string} keyRoot e.g. 'C'
|
|
||||||
* @param {string} keyMode e.g. 'major'
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function toRomanNumeral(chordName, keyRoot, keyMode) {
|
|
||||||
if (!chordName || !keyRoot) return '?'
|
|
||||||
|
|
||||||
const match = chordName.match(/^([A-G][b#]?)(.*)$/)
|
|
||||||
if (!match) return '?'
|
|
||||||
const [, root, quality] = match
|
|
||||||
|
|
||||||
const chordRootIdx = noteIndex(root)
|
|
||||||
const keyRootIdx = noteIndex(keyRoot)
|
|
||||||
if (chordRootIdx < 0 || keyRootIdx < 0) return '?'
|
|
||||||
|
|
||||||
const semitones = ((chordRootIdx - keyRootIdx) + 12) % 12
|
|
||||||
const scale = SCALES[keyMode] ?? SCALES.major
|
|
||||||
const degreeIdx = scale.indexOf(semitones)
|
|
||||||
|
|
||||||
let rn
|
|
||||||
if (degreeIdx >= 0) {
|
|
||||||
rn = ROMAN_NUMERALS[degreeIdx]
|
|
||||||
} else {
|
|
||||||
// Chromatic chord: find the nearest diatonic degree above and flat it
|
|
||||||
const nearestAbove = scale.findIndex(d => d > semitones)
|
|
||||||
const refDegree = nearestAbove >= 0 ? nearestAbove : 0
|
|
||||||
rn = '♭' + ROMAN_NUMERALS[refDegree]
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMinorQuality = /^m(?!aj)/.test(quality) || quality === 'dim' || quality === 'm7b5'
|
|
||||||
return isMinorQuality ? rn.toLowerCase() : rn
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Repeating progression detection ─────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scans recent chord history for a repeating pattern of length 2–6.
|
|
||||||
*
|
|
||||||
* Returns the most recently completed pattern that appears at least twice
|
|
||||||
* within the last 20 chords. Longer patterns that repeat are preferred over
|
|
||||||
* shorter ones via a `repetitions × length` score.
|
|
||||||
*
|
|
||||||
* @param {string[]} history Ordered list of chord names
|
|
||||||
* @returns {string[]|null} Detected repeating pattern, or null
|
|
||||||
*/
|
|
||||||
export function detectRepeatingProgression(history) {
|
|
||||||
if (!history || history.length < 4) return null
|
|
||||||
|
|
||||||
const window = history.slice(-20)
|
|
||||||
let best = null, bestScore = 0
|
|
||||||
|
|
||||||
for (let len = 2; len <= 6; len++) {
|
|
||||||
if (len * 2 > window.length) break
|
|
||||||
|
|
||||||
const candidate = window.slice(-len)
|
|
||||||
let reps = 0
|
|
||||||
|
|
||||||
// Count non-overlapping matches from left to right
|
|
||||||
let i = 0
|
|
||||||
while (i <= window.length - len) {
|
|
||||||
const matches = candidate.every((c, j) => c === window[i + j])
|
|
||||||
if (matches) {
|
|
||||||
reps++
|
|
||||||
i += len // skip past this match to avoid overlaps
|
|
||||||
} else {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const score = reps * len
|
|
||||||
if (reps >= 2 && score > bestScore) {
|
|
||||||
bestScore = score
|
|
||||||
best = candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Utility ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the interval name for a semitone distance (0–11).
|
|
||||||
* @param {number} semitones
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function intervalName(semitones) {
|
|
||||||
const names = [
|
|
||||||
'Unison', 'Minor 2nd', 'Major 2nd', 'Minor 3rd', 'Major 3rd',
|
|
||||||
'Perfect 4th', 'Tritone', 'Perfect 5th', 'Minor 6th',
|
|
||||||
'Major 6th', 'Minor 7th', 'Major 7th',
|
|
||||||
]
|
|
||||||
return names[((semitones % 12) + 12) % 12] ?? 'Unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transposes a chord name by a given number of semitones.
|
|
||||||
* @param {string} chordName e.g. 'Am7'
|
|
||||||
* @param {number} semitones Positive = up, negative = down
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function transposeChord(chordName, semitones) {
|
|
||||||
const match = chordName.match(/^([A-G][b#]?)(.*)$/)
|
|
||||||
if (!match) return chordName
|
|
||||||
const newRoot = noteName(noteIndex(match[1]) + semitones)
|
|
||||||
return newRoot + match[2]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transposes an entire progression by a given number of semitones.
|
|
||||||
* @param {string[]} chords
|
|
||||||
* @param {number} semitones
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
export function transposeProgression(chords, semitones) {
|
|
||||||
return chords.map(c => transposeChord(c, semitones))
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
'/api': 'http://localhost:8000',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="description" content="Real-time key and chord detection for live jams. Play any instrument into your mic and JamBuddy identifies the key, chords, and tempo — fully offline." />
|
||||||
|
<meta name="theme-color" content="#0f0f0f" />
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="JamBuddy" />
|
||||||
|
<!-- Production CSP. dev HMR runs same-origin so 'self' covers the ws upgrade. -->
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' blob:; connect-src 'self';" />
|
||||||
|
<title>WhatTheFlat ♭? - JamBuddy</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import uvicorn
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from api.config import settings
|
|
||||||
from api.routers import chat
|
|
||||||
|
|
||||||
app = FastAPI(title="WhatTheFlat API", version="0.1.0")
|
|
||||||
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=settings.cors_origins,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(chat.router, prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
|
||||||
async def health():
|
|
||||||
return {"status": "ok", "ai_mode": settings.ai_mode}
|
|
||||||
|
|
||||||
|
|
||||||
def serve():
|
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
serve()
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "jambuddy",
|
||||||
|
"description": "A jam session companion app that provides a chromatic tuner, chord progressions, and more.",
|
||||||
|
"version": "0.6.2",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "electron/main.cjs",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"electron:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5173 && electron .\"",
|
||||||
|
"electron:build": "vite build && electron-builder",
|
||||||
|
"electron:build:win": "vite build && electron-builder --win --publish never",
|
||||||
|
"electron:build:mac": "vite build && electron-builder --mac --publish never",
|
||||||
|
"electron:build:linux": "vite build && electron-builder --linux --publish never"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pitchy": "^4.1.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"electron": "^40.7.0",
|
||||||
|
"electron-builder": "^26.8.1",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
|
"wait-on": "^9.0.4"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.jambuddy.app",
|
||||||
|
"productName": "JamBuddy",
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"electron/**/*",
|
||||||
|
"node_modules/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"directories": {
|
||||||
|
"buildResources": "dist",
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"icon": "assets/whattheflat-logo.png",
|
||||||
|
"forceCodeSigning": false
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"hardenedRuntime": true,
|
||||||
|
"entitlements": "electron/entitlements.mac.plist",
|
||||||
|
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||||
|
"extendInfo": {
|
||||||
|
"NSMicrophoneUsageDescription": "WhatTheFlat needs access to your microphone to detect pitch and provide the tuner."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage",
|
||||||
|
"icon": "assets/whattheflat-logo.png"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
# WhatTheFlat 🎸
|
|
||||||
### Product Requirements Document v0.1
|
|
||||||
|
|
||||||
> *Open-source live jam analyzer — helping musicians find the key, follow the harmony, and level up together.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Vision
|
|
||||||
|
|
||||||
**WhatTheFlat** is a free, open-source web app that listens to a live jam session in real time, identifies the key and chord being played, and displays beginner-friendly guidance — scales, next chords, and simple melodic patterns — so every musician in the room can contribute, regardless of experience level.
|
|
||||||
|
|
||||||
The core belief: **jams should be inclusive**. A seasoned guitarist shouldn't have to stop to explain music theory. WhatTheFlat does it for them, silently, in real time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem Statement
|
|
||||||
|
|
||||||
- Amateur musicians struggle to find the key when joining a jam they didn't start
|
|
||||||
- Beginners don't know which notes are "safe" to play over a chord progression
|
|
||||||
- There's no affordable, low-friction tool that bridges music theory and live performance
|
|
||||||
- Existing tools (like guitar tuners or DAWs) are either too simple or too complex
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Target Users
|
|
||||||
|
|
||||||
| User | Description |
|
|
||||||
|---|---|
|
|
||||||
| **The Beginner** | Just started an instrument, wants to follow along without killing the vibe |
|
|
||||||
| **The Intermediate** | Knows a few scales but struggles to apply theory in real time |
|
|
||||||
| **The Jam Host** | Sets up the session and wants everyone to stay in the same key |
|
|
||||||
| **The Teacher** | Uses jams as a teaching tool, wants visual aids for students |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Core Features
|
|
||||||
|
|
||||||
### 4.1 Live Key Detection
|
|
||||||
- Capture audio from device microphone in real time
|
|
||||||
- Analyze incoming audio using pitch detection (e.g. YIN algorithm or ML model)
|
|
||||||
- Identify the **root note** and **mode** (major, minor, dorian, mixolydian, etc.)
|
|
||||||
- Display current detected key prominently: e.g. **"You're in A Minor"**
|
|
||||||
- Show confidence level so users understand when detection is uncertain
|
|
||||||
- Latency target: < 500ms from sound to display
|
|
||||||
|
|
||||||
### 4.2 Chord Recognition
|
|
||||||
- Detect the chord being played in real time
|
|
||||||
- Show chord name: e.g. **Am → F → C → G**
|
|
||||||
- Build a live scrolling chord history for the last 30 seconds
|
|
||||||
- Highlight the current chord in the progression
|
|
||||||
|
|
||||||
### 4.3 Chord Progression Suggestions
|
|
||||||
- Based on detected key, suggest common chord progressions that fit
|
|
||||||
- Categorize by genre/feel: Blues, Jazz, Folk, Rock, Pop
|
|
||||||
- Show the progression in Roman numeral notation (I–IV–V) AND actual chord names
|
|
||||||
- Allow user to tap/click a suggested progression to "lock it in" as a reference
|
|
||||||
|
|
||||||
### 4.4 Beginner Melody Helper
|
|
||||||
- For the detected key, show a visual **"safe notes"** panel — a highlighted instrument diagram
|
|
||||||
- Guitar fretboard view
|
|
||||||
- Piano keyboard view
|
|
||||||
- Generic note list (for other instruments)
|
|
||||||
- Show the **pentatonic scale** first (most beginner-friendly), with option to expand to full scale
|
|
||||||
- Highlight notes that sound especially good over the current chord (chord tones)
|
|
||||||
- Show a simple **melodic pattern of the bar** — a looping, beginner-friendly phrase they can follow
|
|
||||||
|
|
||||||
### 4.5 Visual Jam Dashboard
|
|
||||||
- Clean, readable display meant to be seen from across a room
|
|
||||||
- Show at a glance:
|
|
||||||
- Current key
|
|
||||||
- Current chord
|
|
||||||
- Next likely chord (based on progression history)
|
|
||||||
- Safe notes to play
|
|
||||||
- Dark mode by default (stage-friendly)
|
|
||||||
- Large font, high contrast
|
|
||||||
|
|
||||||
### 4.6 AI Music Theory Assistant (Claude Integration)
|
|
||||||
- Powered by Anthropic's Claude API
|
|
||||||
- Users can ask questions like:
|
|
||||||
- *"What's a good lick to play over this chord?"*
|
|
||||||
- *"Why does the F chord sound tense here?"*
|
|
||||||
- *"Give me a beginner melody in this key"*
|
|
||||||
- Claude responds in plain, non-jargon language
|
|
||||||
- Can suggest chord substitutions, extensions (7ths, 9ths), and passing chords
|
|
||||||
- All responses are **beginner-aware** — no unexplained jargon
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. MVP Scope (v0.1)
|
|
||||||
|
|
||||||
For the first open-source demo, the MVP should deliver:
|
|
||||||
|
|
||||||
- [x] Microphone capture in browser (Web Audio API)
|
|
||||||
- [x] Pitch detection → key identification
|
|
||||||
- [x] Display current key and detected chord
|
|
||||||
- [x] Show pentatonic scale for detected key (note list + basic fretboard)
|
|
||||||
- [x] 3–5 suggested chord progressions that fit the key
|
|
||||||
- [x] Claude-powered chat assistant for theory questions
|
|
||||||
- [x] Dark mode UI, mobile-friendly
|
|
||||||
|
|
||||||
**Not in MVP:**
|
|
||||||
- Multi-instrument separation
|
|
||||||
- MIDI input support
|
|
||||||
- Session recording/export
|
|
||||||
- User accounts or history
|
|
||||||
- Real-time collaboration (multiple users same session)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Technical Architecture
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **Framework**: React (Vite)
|
|
||||||
- **Audio**: Web Audio API + `pitchy` or `aubio.js` for pitch detection
|
|
||||||
- **Visualizations**: `Tone.js` for music utilities, custom SVG for fretboard/keyboard
|
|
||||||
- **Styling**: Tailwind CSS
|
|
||||||
- **Hosting**: Vercel / Netlify (static deploy)
|
|
||||||
|
|
||||||
### AI Layer
|
|
||||||
- **Model**: Claude (`claude-sonnet-4-20250514`) via Anthropic API
|
|
||||||
- **Usage**:
|
|
||||||
- Theory assistant chat
|
|
||||||
- Chord progression generation given key + genre
|
|
||||||
- Melody suggestion generation
|
|
||||||
- **Prompt strategy**: System prompt primes Claude as a friendly, beginner-aware music teacher
|
|
||||||
|
|
||||||
### Audio Pipeline
|
|
||||||
```
|
|
||||||
Microphone Input
|
|
||||||
↓
|
|
||||||
Web Audio API (AudioContext)
|
|
||||||
↓
|
|
||||||
Pitch Detection (YIN / pitchy)
|
|
||||||
↓
|
|
||||||
Note → Chord Identification
|
|
||||||
↓
|
|
||||||
Key Estimation (sliding window analysis)
|
|
||||||
↓
|
|
||||||
UI Update + Claude Context Refresh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Open Source Stack
|
|
||||||
- Repo: GitHub (MIT License)
|
|
||||||
- CI: GitHub Actions
|
|
||||||
- Contributing: standard fork/PR model with CONTRIBUTING.md
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Music Theory Engine
|
|
||||||
|
|
||||||
The core theory logic should be implemented as a standalone JS module (`@whattheflat/theory`) so it can be reused or contributed to independently.
|
|
||||||
|
|
||||||
### Required functions:
|
|
||||||
```
|
|
||||||
detectKey(noteHistory[]) → { root, mode, confidence }
|
|
||||||
getChordsInKey(root, mode) → Chord[]
|
|
||||||
getSuggestedProgressions(root, mode, genre?) → Progression[]
|
|
||||||
getPentatonicScale(root, mode) → Note[]
|
|
||||||
getFullScale(root, mode) → Note[]
|
|
||||||
getChordTones(chord) → Note[]
|
|
||||||
getBeginnerPattern(key, currentChord) → MelodicPattern
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modes to support (MVP):
|
|
||||||
- Major (Ionian)
|
|
||||||
- Natural Minor (Aeolian)
|
|
||||||
- Pentatonic Major
|
|
||||||
- Pentatonic Minor
|
|
||||||
|
|
||||||
### Modes to support (v1+):
|
|
||||||
- Dorian, Mixolydian, Lydian, Phrygian
|
|
||||||
- Blues scale
|
|
||||||
- Harmonic minor
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. UX Principles
|
|
||||||
|
|
||||||
1. **Readable at distance** — primary info visible from 2 metres away
|
|
||||||
2. **Zero setup** — tap "Start Listening", grant mic permission, done
|
|
||||||
3. **Non-judgmental** — never says "wrong note", always says "try these"
|
|
||||||
4. **Progressive complexity** — beginners see pentatonic; advanced users can unlock modes, extensions, substitutions
|
|
||||||
5. **Mobile first** — works on a phone propped up on a music stand
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Beginner Learning Path (stretch goal)
|
|
||||||
|
|
||||||
A guided progression for users who want to improve over time:
|
|
||||||
|
|
||||||
| Level | Unlocks |
|
|
||||||
|---|---|
|
|
||||||
| 🟢 Starter | Pentatonic scale, 3 safe chords |
|
|
||||||
| 🔵 Follower | Full diatonic scale, chord tones highlighted |
|
|
||||||
| 🟣 Contributor | Chord extensions (7th, 9th), passing chords |
|
|
||||||
| 🔴 Improvisor | Modal awareness, substitutions, AI-generated licks |
|
|
||||||
|
|
||||||
Progress is stored in localStorage — no account needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open Source Contribution Areas
|
|
||||||
|
|
||||||
| Area | Skills needed |
|
|
||||||
|---|---|
|
|
||||||
| Pitch detection accuracy | DSP, audio engineering |
|
|
||||||
| Music theory engine | Music theory + JavaScript |
|
|
||||||
| Instrument diagrams | SVG, React |
|
|
||||||
| Genre-specific progressions | Music knowledge |
|
|
||||||
| Claude prompt engineering | AI, music theory |
|
|
||||||
| Accessibility (screen readers, colorblind modes) | A11y |
|
|
||||||
| Mobile app wrapper | React Native / Capacitor |
|
|
||||||
| Translation / i18n | Language + music terminology |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Success Metrics
|
|
||||||
|
|
||||||
- Time from opening app to understanding what key they're in: **< 10 seconds**
|
|
||||||
- % of beginner users who successfully play a note in the right key on first jam: **target 80%**
|
|
||||||
- GitHub stars at 3 months: **500+**
|
|
||||||
- Average Claude assistant response usefulness (self-reported): **> 4/5**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Name & Branding Notes
|
|
||||||
|
|
||||||
- **Name**: WhatTheFlat
|
|
||||||
- **Tagline**: *"Real-time key detection for real humans"*
|
|
||||||
- **Tone**: Warm, slightly cheeky, musician-coded humour
|
|
||||||
- **Logo concept**: A flat symbol (♭) with a question mark or waveform integrated
|
|
||||||
- **Color palette**: Dark background (#0f0f0f), accent in electric purple or warm amber
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Document maintained by the WhatTheFlat open source community. PRs welcome.*
|
|
||||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -1,23 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "whattheflat"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Real-time key detection for real humans"
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
dependencies = [
|
|
||||||
"fastapi>=0.115.0",
|
|
||||||
"uvicorn[standard]>=0.30.0",
|
|
||||||
"anthropic>=0.40.0",
|
|
||||||
"ollama>=0.4.0",
|
|
||||||
"python-dotenv>=1.0.0",
|
|
||||||
"pydantic>=2.0.0",
|
|
||||||
"pydantic-settings>=2.0.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependency-groups]
|
|
||||||
dev = [
|
|
||||||
"httpx>=0.27.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.uv]
|
|
||||||
package = false
|
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect, lazy, Suspense } from 'react'
|
||||||
|
import AudioCapture from './components/AudioCapture'
|
||||||
|
import ProgressionBanner from './components/ProgressionBanner'
|
||||||
|
import ProgressionSuggestions from './components/ProgressionSuggestions'
|
||||||
|
import Fretboard from './components/Fretboard'
|
||||||
|
import BassFretboard from './components/BassFretboard'
|
||||||
|
import Piano from './components/Piano'
|
||||||
|
import Settings from './components/Settings'
|
||||||
|
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
||||||
|
import settingIcon from './assets/setting-icon.png'
|
||||||
|
|
||||||
|
// ponytail: collapsible panels most users never open — lazy-load so the core
|
||||||
|
// pitch/chord flow ships in the initial bundle. Suspense fallback null: they
|
||||||
|
// render behind a toggle button, so there's no visible flash.
|
||||||
|
const Tuner = lazy(() => import('./components/Tuner'))
|
||||||
|
const DebugView = lazy(() => import('./components/DebugView'))
|
||||||
|
const DrumView = lazy(() => import('./components/DrumView'))
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
// Key detection
|
||||||
|
noteHistorySize: 2000, // ~60s of notes — stable across a song section
|
||||||
|
keyVoteWindow: 30, // rolling window of key votes
|
||||||
|
keyVoteThreshold: 20, // 67% consensus — locks in after a few bars
|
||||||
|
chordNoteBoost: 3,
|
||||||
|
// Chord detection
|
||||||
|
chromaSmooth: 8, // 8 frames ≈ 130ms window, checks chord at ~7.5 Hz
|
||||||
|
chordVoteThreshold: 2, // 2 consecutive matches ≈ 260ms — works at any BPM
|
||||||
|
chordMinScore: 0.35, // lenient enough for live guitar signal
|
||||||
|
// Audio input
|
||||||
|
minClarity: 0.80,
|
||||||
|
minVolume: 0.01,
|
||||||
|
// Selected device (null = system default)
|
||||||
|
audioDeviceId: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStored(key, fallback) {
|
||||||
|
try { const v = localStorage.getItem(key); return v !== null ? JSON.parse(v) : fallback }
|
||||||
|
catch { return fallback }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
// ── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
const [config, setConfig] = useState(() => ({ ...DEFAULTS, ...loadStored('wtf_config', {}) }))
|
||||||
|
const configRef = useRef(config)
|
||||||
|
useEffect(() => { configRef.current = config; localStorage.setItem('wtf_config', JSON.stringify(config)) }, [config])
|
||||||
|
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
|
||||||
|
function updateConfig(key, val) {
|
||||||
|
setConfig(prev => ({ ...prev, [key]: val }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Listening state ──────────────────────────────────────────────────────────
|
||||||
|
const [isListening, setIsListening] = useState(false)
|
||||||
|
|
||||||
|
// ── Instrument view + tuner ───────────────────────────────────────────────────
|
||||||
|
const [instrument, setInstrument] = useState('piano') // 'piano' | 'guitar' | 'bass'
|
||||||
|
const [showTuner, setShowTuner] = useState(false)
|
||||||
|
const [showDebug, setShowDebug] = useState(false)
|
||||||
|
const [showDrumView, setShowDrumView] = useState(false)
|
||||||
|
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
|
||||||
|
|
||||||
|
// ── Mic permission error ──────────────────────────────────────────────────────
|
||||||
|
const [micError, setMicError] = useState(null)
|
||||||
|
|
||||||
|
// ── Debug data ────────────────────────────────────────────────────────────────
|
||||||
|
const [debugChroma, setDebugChroma] = useState(null)
|
||||||
|
const [debugCandidates, setDebugCandidates] = useState([])
|
||||||
|
const [debugNoteAnalysis, setDebugNoteAnalysis] = useState(null)
|
||||||
|
const [debugWaveform, setDebugWaveform] = useState(null)
|
||||||
|
|
||||||
|
// ── Stable refs for values used inside callbacks ──────────────────────────────
|
||||||
|
const showDebugRef = useRef(showDebug)
|
||||||
|
const showDrumViewRef = useRef(showDrumView)
|
||||||
|
const lockedKeyRef = useRef(null)
|
||||||
|
const listenStartRef = useRef(null)
|
||||||
|
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
|
||||||
|
useEffect(() => { showDrumViewRef.current = showDrumView }, [showDrumView])
|
||||||
|
useEffect(() => { localStorage.setItem('wtf_monoColor', JSON.stringify(monoColor)) }, [monoColor])
|
||||||
|
useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening])
|
||||||
|
|
||||||
|
// ── BPM estimation from onset timestamps ─────────────────────────────────────
|
||||||
|
const [bpm, setBpm] = useState(null)
|
||||||
|
const onsetTimestampsRef = useRef([])
|
||||||
|
const bpmSmoothRef = useRef(null)
|
||||||
|
|
||||||
|
// ── Key: auto-detected + optional lock ───────────────────────────────────────
|
||||||
|
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
|
||||||
|
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
|
||||||
|
useEffect(() => { lockedKeyRef.current = lockedKey }, [lockedKey])
|
||||||
|
const [lockRoot, setLockRoot] = useState('A')
|
||||||
|
const [lockMode, setLockMode] = useState('minor')
|
||||||
|
|
||||||
|
const effectiveKey = lockedKey ?? keyInfo
|
||||||
|
|
||||||
|
// ── Chord state ───────────────────────────────────────────────────────────────
|
||||||
|
const [chordHistory, setChordHistory] = useState([])
|
||||||
|
const [detectedProgression, setDetectedProgression] = useState(null)
|
||||||
|
|
||||||
|
// ── Top key candidates (shown as quick-lock chips) ────────────────────────────
|
||||||
|
const [topKeyCandidates, setTopKeyCandidates] = useState([])
|
||||||
|
|
||||||
|
// ── Internal refs ─────────────────────────────────────────────────────────────
|
||||||
|
const noteHistoryRef = useRef([])
|
||||||
|
const keyVotesRef = useRef([])
|
||||||
|
const effectiveKeyRef = useRef(null)
|
||||||
|
const chromaRingRef = useRef(
|
||||||
|
Array.from({ length: DEFAULTS.chromaSmooth }, () => new Float32Array(12))
|
||||||
|
)
|
||||||
|
const chromaIdxRef = useRef(0)
|
||||||
|
const chordVotesRef = useRef([])
|
||||||
|
const progressionVoteRef = useRef(null)
|
||||||
|
const progressionMissRef = useRef(0)
|
||||||
|
const pendingKeyRef = useRef(null)
|
||||||
|
|
||||||
|
// Keep refs in sync
|
||||||
|
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
|
||||||
|
|
||||||
|
// Re-init chroma ring when chromaSmooth changes
|
||||||
|
useEffect(() => {
|
||||||
|
chromaRingRef.current = Array.from(
|
||||||
|
{ length: config.chromaSmooth },
|
||||||
|
() => new Float32Array(12)
|
||||||
|
)
|
||||||
|
chromaIdxRef.current = 0
|
||||||
|
}, [config.chromaSmooth])
|
||||||
|
|
||||||
|
// ── Detect progression — require 2 consecutive identical results to commit ────
|
||||||
|
useEffect(() => {
|
||||||
|
const detected = detectRepeatingProgression(chordHistory)
|
||||||
|
if (!detected) {
|
||||||
|
progressionMissRef.current++
|
||||||
|
// Clear stale loop after 4 chord changes with no pattern found
|
||||||
|
if (progressionMissRef.current >= 4) {
|
||||||
|
setDetectedProgression(null)
|
||||||
|
progressionVoteRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
progressionMissRef.current = 0
|
||||||
|
const key = detected.join(',')
|
||||||
|
if (progressionVoteRef.current === key) {
|
||||||
|
setDetectedProgression(detected)
|
||||||
|
} else {
|
||||||
|
progressionVoteRef.current = key
|
||||||
|
}
|
||||||
|
}, [chordHistory])
|
||||||
|
|
||||||
|
// ── New song — full reset ─────────────────────────────────────────────────────
|
||||||
|
function newSong() {
|
||||||
|
const cfg = configRef.current
|
||||||
|
noteHistoryRef.current = []
|
||||||
|
keyVotesRef.current = []
|
||||||
|
chordVotesRef.current = []
|
||||||
|
progressionVoteRef.current = null
|
||||||
|
progressionMissRef.current = 0
|
||||||
|
pendingKeyRef.current = null
|
||||||
|
chromaIdxRef.current = 0
|
||||||
|
chromaRingRef.current = Array.from({ length: cfg.chromaSmooth }, () => new Float32Array(12))
|
||||||
|
onsetTimestampsRef.current = []
|
||||||
|
bpmSmoothRef.current = null
|
||||||
|
listenStartRef.current = Date.now()
|
||||||
|
setKeyInfo(null)
|
||||||
|
setLockedKey(null)
|
||||||
|
effectiveKeyRef.current = null
|
||||||
|
setChordHistory([])
|
||||||
|
setDetectedProgression(null)
|
||||||
|
setTopKeyCandidates([])
|
||||||
|
setBpm(null)
|
||||||
|
setMicError(null)
|
||||||
|
setDebugChroma(null)
|
||||||
|
setDebugCandidates([])
|
||||||
|
setDebugNoteAnalysis(null)
|
||||||
|
setDebugWaveform(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key lock handlers ─────────────────────────────────────────────────────────
|
||||||
|
function applyLock() {
|
||||||
|
const info = { root: lockRoot, mode: lockMode, confidence: 1 }
|
||||||
|
setLockedKey(info)
|
||||||
|
effectiveKeyRef.current = info
|
||||||
|
chordVotesRef.current = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickLock({ root, mode, confidence }) {
|
||||||
|
const info = { root, mode, confidence }
|
||||||
|
setLockedKey(info)
|
||||||
|
effectiveKeyRef.current = info
|
||||||
|
chordVotesRef.current = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLock() {
|
||||||
|
setLockedKey(null)
|
||||||
|
effectiveKeyRef.current = keyInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Waveform handler: feeds oscilloscope / drum view ─────────────────────────
|
||||||
|
const handleWaveform = useCallback((data) => {
|
||||||
|
if (showDebugRef.current || showDrumViewRef.current) {
|
||||||
|
setDebugWaveform({ ...data, onsets: [...onsetTimestampsRef.current] })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── Note handler: drives key detection (pitch-based) ──────────────────────────
|
||||||
|
const handleNote = useCallback(({ pitchClass }) => {
|
||||||
|
const cfg = configRef.current
|
||||||
|
const history = noteHistoryRef.current
|
||||||
|
history.push(pitchClass)
|
||||||
|
if (history.length > cfg.noteHistorySize) history.shift()
|
||||||
|
if (history.length < 10) return
|
||||||
|
if (history.length % 5 !== 0) return
|
||||||
|
|
||||||
|
const result = detectKey(history)
|
||||||
|
setTopKeyCandidates(detectTopKeys(history))
|
||||||
|
if (showDebugRef.current) {
|
||||||
|
const analysis = getNoteHistoryAnalysis(history)
|
||||||
|
analysis.sessionSecs = listenStartRef.current ? Math.floor((Date.now() - listenStartRef.current) / 1000) : 0
|
||||||
|
setDebugNoteAnalysis(analysis)
|
||||||
|
}
|
||||||
|
if (result.confidence < 0.5) return
|
||||||
|
|
||||||
|
const votes = keyVotesRef.current
|
||||||
|
votes.push(`${result.root}_${result.mode}`)
|
||||||
|
if (votes.length > cfg.keyVoteWindow) votes.shift()
|
||||||
|
|
||||||
|
const counts = {}
|
||||||
|
for (const v of votes) counts[v] = (counts[v] || 0) + 1
|
||||||
|
const [winner, count] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]
|
||||||
|
|
||||||
|
if (count >= cfg.keyVoteThreshold) {
|
||||||
|
const [root, mode] = winner.split('_')
|
||||||
|
const candidateKey = `${root}_${mode}`
|
||||||
|
|
||||||
|
setKeyInfo(prev => {
|
||||||
|
const currentKey = prev ? `${prev.root}_${prev.mode}` : null
|
||||||
|
|
||||||
|
if (currentKey === candidateKey) {
|
||||||
|
pendingKeyRef.current = null
|
||||||
|
return { root, mode, confidence: result.confidence }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingKeyRef.current === candidateKey) {
|
||||||
|
pendingKeyRef.current = null
|
||||||
|
if (!lockedKeyRef.current) chordVotesRef.current = []
|
||||||
|
return { root, mode, confidence: result.confidence }
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingKeyRef.current = candidateKey
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── Chroma handler: drives chord detection ────────────────────────────────────
|
||||||
|
const handleChroma = useCallback((chroma, bassPC) => {
|
||||||
|
const cfg = configRef.current
|
||||||
|
const ring = chromaRingRef.current
|
||||||
|
ring[chromaIdxRef.current % cfg.chromaSmooth] = chroma
|
||||||
|
chromaIdxRef.current++
|
||||||
|
if (chromaIdxRef.current % cfg.chromaSmooth !== 0) return
|
||||||
|
|
||||||
|
const key = effectiveKeyRef.current
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
const avg = new Float32Array(12)
|
||||||
|
for (const frame of ring) for (let i = 0; i < 12; i++) avg[i] += frame[i]
|
||||||
|
for (let i = 0; i < 12; i++) avg[i] /= cfg.chromaSmooth
|
||||||
|
|
||||||
|
if (showDebugRef.current) {
|
||||||
|
setDebugChroma([...avg])
|
||||||
|
setDebugCandidates(getChordCandidates(avg, key, bassPC, 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stability gate — if chroma is still changing across frames, we're mid-transition.
|
||||||
|
// Compute per-bin variance across the ring; bail if any bin is fluctuating heavily.
|
||||||
|
let maxVar = 0
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
let v = 0
|
||||||
|
for (const frame of ring) { const d = frame[i] - avg[i]; v += d * d }
|
||||||
|
if (v / cfg.chromaSmooth > maxVar) maxVar = v / cfg.chromaSmooth
|
||||||
|
}
|
||||||
|
if (maxVar > 0.05) return
|
||||||
|
|
||||||
|
const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore)
|
||||||
|
if (!chord) {
|
||||||
|
chordVotesRef.current = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const votes = chordVotesRef.current
|
||||||
|
votes.push(chord)
|
||||||
|
if (votes.length > cfg.chordVoteThreshold) votes.shift()
|
||||||
|
|
||||||
|
if (votes.length >= cfg.chordVoteThreshold && votes.every(v => v === votes[0])) {
|
||||||
|
const winner = votes[0]
|
||||||
|
setChordHistory(prev => {
|
||||||
|
if (prev[prev.length - 1] === winner) return prev
|
||||||
|
return [...prev.slice(-48), winner]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inject chord tones into note history to anchor key detection
|
||||||
|
const chordPCs = getChordTones(winner)
|
||||||
|
.map(n => NOTES.indexOf(n))
|
||||||
|
.filter(i => i >= 0)
|
||||||
|
const history = noteHistoryRef.current
|
||||||
|
for (let j = 0; j < cfg.chordNoteBoost; j++) {
|
||||||
|
for (const pc of chordPCs) history.push(pc)
|
||||||
|
}
|
||||||
|
while (history.length > cfg.noteHistorySize) history.shift()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── Onset handler: drives BPM estimation via tempo histogram ────────────────
|
||||||
|
// Pairwise inter-onset intervals are folded into 55-220 BPM and vote in a
|
||||||
|
// histogram. Works with drums, guitar, piano, or mixed — whatever fires most
|
||||||
|
// consistently wins. Only updates when there's a clear peak (≥20% of votes).
|
||||||
|
const handleOnset = useCallback(() => {
|
||||||
|
const ts = onsetTimestampsRef.current
|
||||||
|
ts.push(performance.now())
|
||||||
|
if (ts.length > 64) ts.shift()
|
||||||
|
if (ts.length < 4) return
|
||||||
|
|
||||||
|
const recent = ts.slice(-24)
|
||||||
|
const bins = new Float32Array(221) // index = BPM (55–220)
|
||||||
|
|
||||||
|
for (let i = 0; i < recent.length - 1; i++) {
|
||||||
|
for (let j = i + 1; j < recent.length && j < i + 8; j++) {
|
||||||
|
const ms = recent[j] - recent[i]
|
||||||
|
if (ms < 140 || ms > 6000) continue
|
||||||
|
|
||||||
|
// Fold interval into 55-220 BPM range (handles subdivisions & half-time)
|
||||||
|
let beatMs = ms
|
||||||
|
while (beatMs > 1091) beatMs /= 2
|
||||||
|
while (beatMs < 273) beatMs *= 2
|
||||||
|
if (beatMs < 273 || beatMs > 1091) continue
|
||||||
|
|
||||||
|
const bpm = Math.round(60000 / beatMs)
|
||||||
|
if (bpm >= 55 && bpm <= 220) bins[bpm] += 1 / (j - i) // weight closer pairs more
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find peak with ±1 BPM smoothing
|
||||||
|
let best = 0, bestBpm = 0
|
||||||
|
for (let b = 56; b <= 219; b++) {
|
||||||
|
const s = bins[b - 1] + bins[b] + bins[b + 1]
|
||||||
|
if (s > best) { best = s; bestBpm = b }
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = bins.reduce((a, v) => a + v, 0)
|
||||||
|
if (total < 1 || best / total < 0.2) return // no clear consensus yet
|
||||||
|
|
||||||
|
const prev = bpmSmoothRef.current
|
||||||
|
bpmSmoothRef.current = prev === null ? bestBpm : 0.25 * bestBpm + 0.75 * prev
|
||||||
|
setBpm(Math.round(bpmSmoothRef.current))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const currentChord = chordHistory[chordHistory.length - 1]
|
||||||
|
|
||||||
|
if (showSettings) {
|
||||||
|
return (
|
||||||
|
<Settings
|
||||||
|
config={config}
|
||||||
|
onChange={updateConfig}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
onReset={() => { setConfig(DEFAULTS); setMonoColor(false) }}
|
||||||
|
monoColor={monoColor}
|
||||||
|
onMonoColorChange={setMonoColor}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell min-h-screen bg-surface text-white">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<header className="mb-2 flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-accent">
|
||||||
|
WhatTheFlat <span className="text-gray-600">♭?</span> <span className="text-amber-400">- JamBuddy</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-gray-600 hidden sm:block">Real-time key detection for live jams</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
className="p-2 rounded-full border border-border hover:border-gray-400 transition-all"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<img src={settingIcon} alt="Settings" className="w-5 h-5" style={{ filter: 'invert(1) opacity(0.75)' }} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={newSong}
|
||||||
|
className="group px-5 py-2 rounded-full text-sm font-semibold border border-border text-gray-400 hover:text-gray-200 hover:border-gray-400 transition-all"
|
||||||
|
>
|
||||||
|
<span className="group-hover:hidden">New Song</span>
|
||||||
|
<span className="hidden group-hover:inline">Clear History</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMicError(null); setIsListening(l => !l) }}
|
||||||
|
className={`px-5 py-2 rounded-full font-semibold text-sm transition-all ${
|
||||||
|
isListening
|
||||||
|
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||||
|
: 'bg-accent hover:bg-purple-600 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isListening ? 'Stop' : 'Start Listening'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Controls bar ── */}
|
||||||
|
<div className="mb-2 flex flex-wrap gap-2 items-center p-2 bg-panel border border-border rounded-xl">
|
||||||
|
|
||||||
|
{/* Instrument select */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={instrument}
|
||||||
|
onChange={e => setInstrument(e.target.value)}
|
||||||
|
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<option value="piano">Piano</option>
|
||||||
|
<option value="guitar">Guitar</option>
|
||||||
|
<option value="bass">Bass</option>
|
||||||
|
</select>
|
||||||
|
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BPM badge */}
|
||||||
|
{bpm && (
|
||||||
|
<span className="px-3 py-1 bg-accent/10 border border-accent/30 rounded-lg text-sm text-accent font-mono tabular-nums">
|
||||||
|
♩ <span className="inline-block w-[3ch] text-right">{Math.round(bpm)}</span> <span className="text-accent/50 text-xs">BPM</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="w-px h-5 bg-border shrink-0" />
|
||||||
|
|
||||||
|
{lockedKey ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-accent/20 border border-accent rounded-full">
|
||||||
|
<span className="text-accent text-sm font-semibold shrink-0">🔒 {lockedKey.root}</span>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={lockedKey.mode}
|
||||||
|
onChange={e => {
|
||||||
|
const info = { ...lockedKey, mode: e.target.value }
|
||||||
|
setLockedKey(info)
|
||||||
|
effectiveKeyRef.current = info
|
||||||
|
chordVotesRef.current = []
|
||||||
|
}}
|
||||||
|
className="appearance-none bg-transparent text-accent text-sm font-semibold border-none outline-none cursor-pointer pr-4"
|
||||||
|
>
|
||||||
|
<option value="major">Major</option>
|
||||||
|
<option value="minor">Minor</option>
|
||||||
|
<option value="dorian">Dorian</option>
|
||||||
|
<option value="mixolydian">Mixolydian</option>
|
||||||
|
<option value="phrygian">Phrygian</option>
|
||||||
|
<option value="lydian">Lydian</option>
|
||||||
|
</select>
|
||||||
|
<span className="pointer-events-none absolute right-0 top-1/2 -translate-y-1/2 text-accent/60 text-xs">▾</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={removeLock} className="text-xs text-accent/50 hover:text-accent transition-colors">
|
||||||
|
unlock
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
{topKeyCandidates.map((k, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => quickLock(k)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-semibold border transition-all ${
|
||||||
|
i === 0
|
||||||
|
? 'border-accent text-accent hover:bg-accent/10'
|
||||||
|
: 'border-border text-gray-400 hover:border-gray-500 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{k.root} {k.mode === 'major' ? 'maj' : 'min'} · {Math.round(k.confidence * 100)}%
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{topKeyCandidates.length > 0 && <span className="text-gray-600 text-xs">or</span>}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={lockRoot}
|
||||||
|
onChange={e => setLockRoot(e.target.value)}
|
||||||
|
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
{NOTES.map(n => <option key={n}>{n}</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={lockMode}
|
||||||
|
onChange={e => setLockMode(e.target.value)}
|
||||||
|
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<option value="major">Major</option>
|
||||||
|
<option value="minor">Minor</option>
|
||||||
|
<option value="dorian">Dorian</option>
|
||||||
|
<option value="mixolydian">Mixolydian</option>
|
||||||
|
<option value="phrygian">Phrygian</option>
|
||||||
|
<option value="lydian">Lydian</option>
|
||||||
|
</select>
|
||||||
|
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={applyLock}
|
||||||
|
className="px-3 py-1 bg-accent/10 hover:bg-accent/20 border border-accent/40 hover:border-accent text-accent text-sm rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
Lock key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AudioCapture
|
||||||
|
onNote={handleNote}
|
||||||
|
onChroma={handleChroma}
|
||||||
|
onOnset={handleOnset}
|
||||||
|
onWaveform={handleWaveform}
|
||||||
|
isListening={isListening}
|
||||||
|
minClarity={config.minClarity}
|
||||||
|
minVolume={config.minVolume}
|
||||||
|
audioDeviceId={config.audioDeviceId}
|
||||||
|
onPermissionError={() => {
|
||||||
|
setMicError(true)
|
||||||
|
setIsListening(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{micError && (
|
||||||
|
<div className="mb-2 px-4 py-3 rounded-xl border border-red-800 bg-red-900/20 text-sm text-red-400 flex items-center justify-between">
|
||||||
|
<span>Microphone permission denied. Please allow microphone access in your browser or OS settings and try again.</span>
|
||||||
|
<button onClick={() => setMicError(null)} className="ml-4 text-red-600 hover:text-red-400 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Progression banner ── */}
|
||||||
|
<ProgressionBanner
|
||||||
|
chordHistory={chordHistory}
|
||||||
|
keyInfo={effectiveKey}
|
||||||
|
detectedProgression={detectedProgression}
|
||||||
|
currentChord={currentChord}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Instrument + progressions row ── */}
|
||||||
|
<div className="flex flex-col lg:flex-row gap-3 mb-3 items-stretch">
|
||||||
|
<div className="w-full lg:w-[70%] min-w-0">
|
||||||
|
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} />}
|
||||||
|
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||||
|
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full lg:w-[30%] min-w-0 lg:relative">
|
||||||
|
<div className="lg:absolute lg:inset-0">
|
||||||
|
<ProgressionSuggestions keyInfo={effectiveKey} currentChord={currentChord} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Behind the scenes — collapsible ── */}
|
||||||
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDebug(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
|
||||||
|
>
|
||||||
|
<span>BEHIND THE SCENES</span>
|
||||||
|
<span>{showDebug ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{showDebug && (
|
||||||
|
<div className="border-t border-border p-4">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DebugView
|
||||||
|
chroma={debugChroma}
|
||||||
|
chordCandidates={debugCandidates}
|
||||||
|
noteAnalysis={debugNoteAnalysis}
|
||||||
|
waveform={debugWaveform}
|
||||||
|
keyInfo={effectiveKey}
|
||||||
|
currentChord={currentChord}
|
||||||
|
instrument={instrument}
|
||||||
|
monoColor={monoColor}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Rhythm / drum analyser — collapsible ── */}
|
||||||
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDrumView(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
|
||||||
|
>
|
||||||
|
<span>RHYTHM ANALYSER</span>
|
||||||
|
<span>{showDrumView ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{showDrumView && (
|
||||||
|
<div className="border-t border-border p-4">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DrumView waveform={debugWaveform} bpm={bpm} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tuner — collapsible ── */}
|
||||||
|
<div className="bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTuner(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
|
||||||
|
>
|
||||||
|
<span>TUNER</span>
|
||||||
|
<span>{showTuner ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{showTuner && <div className="border-t border-border"><Suspense fallback={null}><Tuner /></Suspense></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 202 KiB |
@@ -0,0 +1,274 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { PitchDetector } from 'pitchy'
|
||||||
|
import { NOTES } from '../lib/theory'
|
||||||
|
|
||||||
|
// ─── Why two analysers? ───────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The Web Audio FFT has linearly-spaced bins: bin width = sampleRate / fftSize.
|
||||||
|
//
|
||||||
|
// fftSize 4096 → ~10.8 Hz/bin (default we were using)
|
||||||
|
// fftSize 16384 → ~2.7 Hz/bin (multi-rate chord analyser)
|
||||||
|
//
|
||||||
|
// On the low guitar strings the gap between adjacent semitones is only ~5-6 Hz.
|
||||||
|
// At 10.8 Hz/bin we literally cannot separate A2 (110 Hz) from A#2 (116 Hz).
|
||||||
|
// That is the single biggest source of wrong chord notes on the low strings.
|
||||||
|
//
|
||||||
|
// Solution: run a second, larger analyser just for chord/chroma detection.
|
||||||
|
// The pitch analyser stays small (4096) so pitchy has a 90ms window — fast
|
||||||
|
// enough for responsive pitch detection. The chord analyser uses 16384 (~370ms
|
||||||
|
// window) — slower to respond but with 2.7 Hz bins that can cleanly separate
|
||||||
|
// every semitone across the guitar's entire range.
|
||||||
|
//
|
||||||
|
// This is an approximation of the Constant-Q Transform (CQT) your friend
|
||||||
|
// mentioned: CQT achieves log-spaced bins mathematically; we approximate it
|
||||||
|
// by simply using a much larger FFT window.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PITCH_FFT = 4096 // ~90ms window — good temporal resolution for pitch
|
||||||
|
const CHORD_FFT = 16384 // ~370ms window — 2.7 Hz/bin, separates low semitones
|
||||||
|
const NOISE_FLOOR = -65 // dB
|
||||||
|
|
||||||
|
// ─── Harmonic summation chroma ────────────────────────────────────────────────
|
||||||
|
// Each FFT bin votes back toward lower fundamentals that could have generated
|
||||||
|
// it as an overtone. This undoes the harmonic contamination that makes minor
|
||||||
|
// chords look like major ones (the 5th harmonic of the root lands on the major
|
||||||
|
// 3rd, which is NOT in the minor chord).
|
||||||
|
const HARMONIC_WEIGHTS = [1.0, 0.5, 0.33, 0.25, 0.2] // h = 1…5
|
||||||
|
|
||||||
|
function computeChroma(freqData, sampleRate, fftSize) {
|
||||||
|
const chroma = new Float32Array(12)
|
||||||
|
const binHz = sampleRate / fftSize
|
||||||
|
const N = freqData.length
|
||||||
|
|
||||||
|
for (let bin = 2; bin < N; bin++) {
|
||||||
|
const freq = bin * binHz
|
||||||
|
if (freq < 80 || freq > 4000) continue
|
||||||
|
const db = freqData[bin]
|
||||||
|
if (db < NOISE_FLOOR) continue
|
||||||
|
|
||||||
|
const amp = Math.sqrt(Math.pow(10, db / 10)) // amplitude, not power
|
||||||
|
|
||||||
|
for (let h = 1; h <= HARMONIC_WEIGHTS.length; h++) {
|
||||||
|
const fundamental = freq / h
|
||||||
|
if (fundamental < 40 || fundamental > 2000) continue
|
||||||
|
const midi = 12 * Math.log2(fundamental / 440) + 69
|
||||||
|
const pc = ((Math.round(midi) % 12) + 12) % 12
|
||||||
|
chroma[pc] += amp * HARMONIC_WEIGHTS[h - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 12; i++) chroma[i] = Math.log1p(chroma[i])
|
||||||
|
const max = Math.max(...chroma)
|
||||||
|
if (max > 0) for (let i = 0; i < 12; i++) chroma[i] /= max
|
||||||
|
return chroma
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBassPC(freqData, sampleRate, fftSize) {
|
||||||
|
const binHz = sampleRate / fftSize
|
||||||
|
let maxPower = 0, bestMidi = -1
|
||||||
|
for (let bin = 2; bin < freqData.length; bin++) {
|
||||||
|
const freq = bin * binHz
|
||||||
|
if (freq < 40 || freq > 350) continue
|
||||||
|
const db = freqData[bin]
|
||||||
|
if (db < NOISE_FLOOR) continue
|
||||||
|
const power = Math.pow(10, db / 10)
|
||||||
|
if (power > maxPower) {
|
||||||
|
maxPower = power
|
||||||
|
bestMidi = Math.round(12 * Math.log2(freq / 440) + 69)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestMidi < 0) return null
|
||||||
|
return ((bestMidi % 12) + 12) % 12
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, audioDeviceId = null }) {
|
||||||
|
const audioCtxRef = useRef(null)
|
||||||
|
const timeBufRef = useRef(null)
|
||||||
|
const freqBufRef = useRef(null)
|
||||||
|
const detectorRef = useRef(null)
|
||||||
|
const rafRef = useRef(null)
|
||||||
|
const streamRef = useRef(null)
|
||||||
|
const activeRef = useRef(false) // guards against stale tick callbacks
|
||||||
|
|
||||||
|
// All callbacks and thresholds read via refs — so start/stop never need to recreate
|
||||||
|
const onNoteRef = useRef(onNote)
|
||||||
|
const onChromaRef = useRef(onChroma)
|
||||||
|
const onOnsetRef = useRef(onOnset)
|
||||||
|
const onWaveformRef = useRef(onWaveform)
|
||||||
|
const onPermissionErrorRef = useRef(onPermissionError)
|
||||||
|
const minClarityRef = useRef(minClarity)
|
||||||
|
const minVolumeRef = useRef(minVolume)
|
||||||
|
const smoothRmsRef = useRef(0)
|
||||||
|
const lastOnsetRef = useRef(0)
|
||||||
|
const specPeakRef = useRef(null) // peak-hold spectrum for display lingering
|
||||||
|
useEffect(() => { onNoteRef.current = onNote }, [onNote])
|
||||||
|
useEffect(() => { onChromaRef.current = onChroma }, [onChroma])
|
||||||
|
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
|
||||||
|
useEffect(() => { onWaveformRef.current = onWaveform }, [onWaveform])
|
||||||
|
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
||||||
|
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
||||||
|
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
activeRef.current = false
|
||||||
|
specPeakRef.current = null
|
||||||
|
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
|
||||||
|
if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null }
|
||||||
|
if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
stop()
|
||||||
|
let stream
|
||||||
|
try {
|
||||||
|
// Helpful debug: list available media devices before requesting permission
|
||||||
|
try {
|
||||||
|
if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
const audioIns = devices.filter(d => d.kind === 'audioinput')
|
||||||
|
console.log('Audio inputs available:', audioIns)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('enumerateDevices failed', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraints = audioDeviceId
|
||||||
|
? { audio: { deviceId: { exact: audioDeviceId } } }
|
||||||
|
: { audio: true }
|
||||||
|
|
||||||
|
console.log('Requesting getUserMedia with constraints:', constraints)
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||||
|
} catch (err) {
|
||||||
|
// If permission denied or other error, surface extra diagnostics when possible
|
||||||
|
console.warn('getUserMedia failed', err)
|
||||||
|
try {
|
||||||
|
if (navigator.permissions && navigator.permissions.query) {
|
||||||
|
const p = await navigator.permissions.query({ name: 'microphone' })
|
||||||
|
console.log('microphone permission state:', p.state)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore; not all environments support Permissions API for microphone
|
||||||
|
}
|
||||||
|
onPermissionErrorRef.current?.(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
streamRef.current = stream
|
||||||
|
activeRef.current = true
|
||||||
|
smoothRmsRef.current = 0
|
||||||
|
lastOnsetRef.current = 0
|
||||||
|
|
||||||
|
const ctx = new AudioContext()
|
||||||
|
audioCtxRef.current = ctx
|
||||||
|
const source = ctx.createMediaStreamSource(stream)
|
||||||
|
|
||||||
|
// Debug: log the acquired audio tracks and labels/deviceIds
|
||||||
|
try {
|
||||||
|
const tracks = stream.getAudioTracks()
|
||||||
|
console.log('Acquired audio tracks:', tracks.map(t => ({ label: t.label, id: t.id, enabled: t.enabled, muted: t.muted })))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not inspect stream tracks', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small analyser — pitch detection needs fast time-domain data
|
||||||
|
const pa = ctx.createAnalyser()
|
||||||
|
pa.fftSize = PITCH_FFT
|
||||||
|
pa.smoothingTimeConstant = 0.0
|
||||||
|
source.connect(pa)
|
||||||
|
timeBufRef.current = new Float32Array(pa.fftSize)
|
||||||
|
detectorRef.current = PitchDetector.forFloat32Array(pa.fftSize)
|
||||||
|
|
||||||
|
// Large analyser — chord detection needs fine frequency resolution
|
||||||
|
const ca = ctx.createAnalyser()
|
||||||
|
ca.fftSize = CHORD_FFT
|
||||||
|
ca.smoothingTimeConstant = 0.5 // reduced from 0.65 — clears faster between chords
|
||||||
|
source.connect(ca)
|
||||||
|
freqBufRef.current = new Float32Array(ca.frequencyBinCount)
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (!activeRef.current) return // stop() was called — bail immediately
|
||||||
|
|
||||||
|
const timeBuf = timeBufRef.current
|
||||||
|
pa.getFloatTimeDomainData(timeBuf)
|
||||||
|
|
||||||
|
const rms = Math.sqrt(timeBuf.reduce((s, v) => s + v * v, 0) / timeBuf.length)
|
||||||
|
|
||||||
|
// Onset detection — RMS spike significantly above smoothed baseline
|
||||||
|
const sr = smoothRmsRef.current
|
||||||
|
smoothRmsRef.current = 0.85 * sr + 0.15 * rms
|
||||||
|
const nowMs = performance.now()
|
||||||
|
if (rms > sr * 2.2 && rms > minVolumeRef.current * 1.5 && nowMs - lastOnsetRef.current > 120) {
|
||||||
|
lastOnsetRef.current = nowMs
|
||||||
|
onOnsetRef.current?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always fire waveform callback — downsample 4096 → 512 points + log-binned spectrum
|
||||||
|
if (onWaveformRef.current) {
|
||||||
|
const stride = 8 // 4096 / 8 = 512 points
|
||||||
|
const wave = new Float32Array(PITCH_FFT / stride)
|
||||||
|
for (let i = 0; i < wave.length; i++) wave[i] = timeBuf[i * stride]
|
||||||
|
|
||||||
|
// Log-binned frequency spectrum: 256 bins from 40 Hz → 4000 Hz
|
||||||
|
const LOG_BINS = 256
|
||||||
|
const F_MIN = 40, F_MAX = 4000
|
||||||
|
const binHz = ctx.sampleRate / ca.fftSize
|
||||||
|
const freqBuf = freqBufRef.current
|
||||||
|
ca.getFloatFrequencyData(freqBuf)
|
||||||
|
const spectrum = new Float32Array(LOG_BINS)
|
||||||
|
for (let b = 0; b < LOG_BINS; b++) {
|
||||||
|
const f = F_MIN * Math.pow(F_MAX / F_MIN, b / (LOG_BINS - 1))
|
||||||
|
const bin = Math.round(f / binHz)
|
||||||
|
if (bin < freqBuf.length) {
|
||||||
|
const db = freqBuf[bin]
|
||||||
|
spectrum[b] = db < NOISE_FLOOR ? 0 : Math.max(0, (db - NOISE_FLOOR) / (-NOISE_FLOOR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak-hold with exponential decay — spectrum rises instantly, falls slowly
|
||||||
|
if (!specPeakRef.current) specPeakRef.current = new Float32Array(LOG_BINS)
|
||||||
|
const peak = specPeakRef.current
|
||||||
|
for (let b = 0; b < LOG_BINS; b++) {
|
||||||
|
peak[b] = spectrum[b] > peak[b] ? spectrum[b] : peak[b] * 0.92
|
||||||
|
}
|
||||||
|
|
||||||
|
let detectedFreq = null, detectedNote = null
|
||||||
|
if (rms >= minVolumeRef.current) {
|
||||||
|
const [f, c] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
|
||||||
|
if (c >= minClarityRef.current && f > 60 && f < 4200) {
|
||||||
|
detectedFreq = f
|
||||||
|
detectedNote = NOTES[((Math.round(12 * Math.log2(f / 440) + 69) % 12) + 12) % 12]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onWaveformRef.current({ wave, rms, detectedFreq, detectedNote, spectrum: peak })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rms >= minVolumeRef.current) {
|
||||||
|
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
|
||||||
|
if (clarity >= minClarityRef.current && freq > 60 && freq < 4200) {
|
||||||
|
const midi = Math.round(12 * Math.log2(freq / 440) + 69)
|
||||||
|
const pitchClass = ((midi % 12) + 12) % 12
|
||||||
|
onNoteRef.current({ noteName: NOTES[pitchClass], pitchClass, freq, midi, clarity })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onChromaRef.current) {
|
||||||
|
const freqBuf = freqBufRef.current
|
||||||
|
ca.getFloatFrequencyData(freqBuf)
|
||||||
|
onChromaRef.current(
|
||||||
|
computeChroma(freqBuf, ctx.sampleRate, ca.fftSize),
|
||||||
|
detectBassPC(freqBuf, ctx.sampleRate, ca.fftSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
}, [stop])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isListening) start().catch(console.error)
|
||||||
|
else stop()
|
||||||
|
return stop
|
||||||
|
}, [isListening, start, stop])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { getPentatonicScale, getFullScale, getChordTones, NOTES } from '../lib/theory'
|
||||||
|
|
||||||
|
// Standard bass tuning (top of diagram = highest string)
|
||||||
|
const STRINGS = [
|
||||||
|
{ label: 'G', root: 7, thickness: 1.5 },
|
||||||
|
{ label: 'D', root: 2, thickness: 2 },
|
||||||
|
{ label: 'A', root: 9, thickness: 2.5 },
|
||||||
|
{ label: 'E', root: 4, thickness: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const NUM_FRETS = 13
|
||||||
|
const FRET_MARKERS = [3, 5, 7, 9]
|
||||||
|
const DOUBLE_MARKER = 12
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
const NUT_X = 40
|
||||||
|
const OPEN_X = 18
|
||||||
|
const FRET_W = 52
|
||||||
|
const STRING_H = 36 // wider spacing than guitar — 4 strings feel more spread
|
||||||
|
const PAD_T = 28
|
||||||
|
const PAD_B = 18
|
||||||
|
const BOARD_W = NUT_X + (NUM_FRETS - 1) * FRET_W + 10
|
||||||
|
const BOARD_H = PAD_T + 3 * STRING_H + PAD_B
|
||||||
|
const DOT_R = 10
|
||||||
|
|
||||||
|
const fretX = f => NUT_X + (f - 0.5) * FRET_W
|
||||||
|
const stringY = si => PAD_T + si * STRING_H
|
||||||
|
|
||||||
|
function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
||||||
|
if (isChordTone) return { fill: '#a855f7', text: '#fff' }
|
||||||
|
if (isPenta) return mono ? { fill: '#c084fc', text: '#1e1b4b' } : { fill: '#f59e0b', text: '#000' }
|
||||||
|
if (isScale) return mono ? { fill: '#e9d5ff', text: '#581c87' } : { fill: '#374151', text: '#d1d5db' }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BassFretboard({ keyInfo, currentChord, monoColor = false }) {
|
||||||
|
const { root, mode } = keyInfo ?? {}
|
||||||
|
|
||||||
|
if (!root) return null
|
||||||
|
|
||||||
|
const pentaSet = new Set(getPentatonicScale(root, mode).map(n => NOTES.indexOf(n)))
|
||||||
|
const scaleSet = new Set(getFullScale(root, mode).map(n => NOTES.indexOf(n)))
|
||||||
|
const chordSet = currentChord
|
||||||
|
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
||||||
|
: new Set()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||||
|
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||||
|
Bass — {root} {mode}
|
||||||
|
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||||
|
width="100%"
|
||||||
|
height="auto"
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
{/* Fretboard background */}
|
||||||
|
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={3 * STRING_H + 12}
|
||||||
|
fill="#1a120b" rx={2} />
|
||||||
|
|
||||||
|
{/* Position marker dots (centred between strings 1–2) */}
|
||||||
|
{FRET_MARKERS.map(f => (
|
||||||
|
<circle key={f}
|
||||||
|
cx={fretX(f)} cy={PAD_T + 1.5 * STRING_H}
|
||||||
|
r={5} fill="#3a2a1a" />
|
||||||
|
))}
|
||||||
|
{/* Double dot at 12 */}
|
||||||
|
<circle cx={fretX(DOUBLE_MARKER)} cy={PAD_T + 0.5 * STRING_H} r={5} fill="#3a2a1a" />
|
||||||
|
<circle cx={fretX(DOUBLE_MARKER)} cy={PAD_T + 2.5 * STRING_H} r={5} fill="#3a2a1a" />
|
||||||
|
|
||||||
|
{/* Fret lines */}
|
||||||
|
{Array.from({ length: NUM_FRETS - 1 }, (_, i) => i + 1).map(f => (
|
||||||
|
<line key={f}
|
||||||
|
x1={NUT_X + f * FRET_W} y1={PAD_T - 6}
|
||||||
|
x2={NUT_X + f * FRET_W} y2={PAD_T + 3 * STRING_H + 6}
|
||||||
|
stroke={f === DOUBLE_MARKER ? '#888' : '#4a3a2a'}
|
||||||
|
strokeWidth={f === DOUBLE_MARKER ? 2 : 1} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Nut */}
|
||||||
|
<line x1={NUT_X} y1={PAD_T - 6} x2={NUT_X} y2={PAD_T + 3 * STRING_H + 6}
|
||||||
|
stroke="#c0b090" strokeWidth={4} />
|
||||||
|
|
||||||
|
{/* Strings — thicker as pitch drops */}
|
||||||
|
{STRINGS.map((s, si) => (
|
||||||
|
<line key={si}
|
||||||
|
x1={OPEN_X - DOT_R - 2} y1={stringY(si)}
|
||||||
|
x2={BOARD_W - 8} y2={stringY(si)}
|
||||||
|
stroke="#9ca3af"
|
||||||
|
strokeWidth={s.thickness} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Fret numbers */}
|
||||||
|
{[3, 5, 7, 9, 12].map(f => (
|
||||||
|
<text key={f}
|
||||||
|
x={fretX(f)} y={PAD_T - 10}
|
||||||
|
textAnchor="middle" fontSize={10} fill="#6b7280"
|
||||||
|
>{f}</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* String labels */}
|
||||||
|
{STRINGS.map((s, si) => (
|
||||||
|
<text key={si}
|
||||||
|
x={6} y={stringY(si) + 4}
|
||||||
|
textAnchor="middle" fontSize={10} fill="#6b7280"
|
||||||
|
>{s.label}</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Note dots */}
|
||||||
|
{STRINGS.flatMap((str, si) =>
|
||||||
|
Array.from({ length: NUM_FRETS }, (_, fi) => {
|
||||||
|
const pc = (str.root + fi) % 12
|
||||||
|
const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc), monoColor)
|
||||||
|
if (!color) return null
|
||||||
|
|
||||||
|
const cx = fi === 0 ? OPEN_X : fretX(fi)
|
||||||
|
const cy = stringY(si)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`${si}-${fi}`}>
|
||||||
|
<circle cx={cx} cy={cy} r={DOT_R} fill={color.fill} />
|
||||||
|
<text
|
||||||
|
x={cx} y={cy + 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fontWeight="600"
|
||||||
|
fill={color.text}
|
||||||
|
>
|
||||||
|
{NOTES[pc]}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
||||||
|
<span><span className="text-accent">●</span> Chord tone</span>
|
||||||
|
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||||
|
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
|
import { getScale, getChordTones, NOTES } from '../lib/theory'
|
||||||
|
|
||||||
|
// ─── SVG Piano — 2 octaves (C3–B4) ───────────────────────────────────────────
|
||||||
|
const KEY_W = 30
|
||||||
|
const KEY_H = 80
|
||||||
|
const BLACK_W = 18
|
||||||
|
const BLACK_H = 50
|
||||||
|
const PIANO_W = 14 * KEY_W
|
||||||
|
|
||||||
|
const WHITE_OCT = [0, 2, 4, 5, 7, 9, 11] // pitch classes per octave
|
||||||
|
const BLACK_OCT = [
|
||||||
|
{ pc: 1, wi: 0 }, { pc: 3, wi: 1 }, { pc: 6, wi: 3 },
|
||||||
|
{ pc: 8, wi: 4 }, { pc: 10, wi: 5 },
|
||||||
|
]
|
||||||
|
const WHITE_LABELS = ['C3','D3','E3','F3','G3','A3','B3','C4','D4','E4','F4','G4','A4','B4']
|
||||||
|
|
||||||
|
function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false, monoColor = false }) {
|
||||||
|
const max = Math.max(...values, 0.01)
|
||||||
|
const wKeys = []
|
||||||
|
const bKeys = []
|
||||||
|
for (let oct = 0; oct < 2; oct++) {
|
||||||
|
WHITE_OCT.forEach((pc, wi) => wKeys.push({ pc, wi: oct * 7 + wi }))
|
||||||
|
BLACK_OCT.forEach(({ pc, wi }) => bKeys.push({ pc, wi: oct * 7 + wi }))
|
||||||
|
}
|
||||||
|
const svgH = keyH + 6 + (showPct ? 16 : 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${PIANO_W} ${svgH}`} width="100%" style={{ display: 'block' }}>
|
||||||
|
{/* White keys */}
|
||||||
|
{wKeys.map(({ pc, wi }) => {
|
||||||
|
const energy = values[pc] / max
|
||||||
|
const inChord = chordNotes?.has(pc)
|
||||||
|
const inKey = keyNotes?.has(pc)
|
||||||
|
const x = wi * KEY_W
|
||||||
|
const fillColor = inChord
|
||||||
|
? `rgba(167,139,250,${0.12 + energy * 0.88})`
|
||||||
|
: inKey
|
||||||
|
? monoColor ? `rgba(192,132,252,${0.1 + energy * 0.7})` : `rgba(251,191,36,${0.1 + energy * 0.7})`
|
||||||
|
: `rgba(180,180,190,${0.05 + energy * 0.2})`
|
||||||
|
const pct = showPct ? Math.round(values[pc] * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`w${wi}`}>
|
||||||
|
<rect x={x+1} y={3} width={KEY_W-2} height={keyH}
|
||||||
|
rx={3} fill="rgb(20,20,26)" stroke="rgba(255,255,255,0.08)" strokeWidth={1} />
|
||||||
|
{energy > (inChord || inKey ? 0.12 : 0.35) && (
|
||||||
|
<rect
|
||||||
|
x={x+1} y={3 + keyH * (1 - Math.min(energy, 1) * 0.85)}
|
||||||
|
width={KEY_W-2} height={keyH * Math.min(energy, 1) * 0.85}
|
||||||
|
rx={2} fill={fillColor} />
|
||||||
|
)}
|
||||||
|
<text x={x + KEY_W/2} y={keyH - 4} textAnchor="middle" fontSize={8}
|
||||||
|
fill={inKey || inChord ? 'rgba(200,200,210,0.9)' : 'rgba(90,90,100,0.8)'}>
|
||||||
|
{WHITE_LABELS[wi]}
|
||||||
|
</text>
|
||||||
|
{showPct && pct > 0 && (
|
||||||
|
<text x={x + KEY_W/2} y={keyH + 14} textAnchor="middle" fontSize={8}
|
||||||
|
fill={inKey ? (monoColor ? 'rgb(192,132,252)' : 'rgb(251,191,36)') : 'rgba(100,100,110,0.8)'}>
|
||||||
|
{pct}%
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Black keys */}
|
||||||
|
{bKeys.map(({ pc, wi }, i) => {
|
||||||
|
const energy = values[pc] / max
|
||||||
|
const inChord = chordNotes?.has(pc)
|
||||||
|
const inKey = keyNotes?.has(pc)
|
||||||
|
const x = wi * KEY_W + KEY_W - BLACK_W / 2
|
||||||
|
const fillColor = inChord
|
||||||
|
? 'rgba(139,92,246,0.9)'
|
||||||
|
: inKey
|
||||||
|
? monoColor ? 'rgba(192,132,252,0.85)' : 'rgba(180,130,0,0.85)'
|
||||||
|
: 'rgba(70,70,80,0.75)'
|
||||||
|
const pct = showPct ? Math.round(values[pc] * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`b${i}`}>
|
||||||
|
{/* Base */}
|
||||||
|
<rect x={x} y={3} width={BLACK_W} height={BLACK_H}
|
||||||
|
rx={2} fill="rgb(14,14,18)" stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
|
||||||
|
{/* Partial fill from bottom — same mechanic as white keys */}
|
||||||
|
{energy > (inChord || inKey ? 0.05 : 0.35) && (
|
||||||
|
<rect
|
||||||
|
x={x} y={3 + BLACK_H * (1 - Math.min(energy, 1) * 0.9)}
|
||||||
|
width={BLACK_W} height={BLACK_H * Math.min(energy, 1) * 0.9}
|
||||||
|
rx={1} fill={fillColor} />
|
||||||
|
)}
|
||||||
|
{/* % label near top of key (inside) */}
|
||||||
|
{showPct && pct > 0 && (
|
||||||
|
<text x={x + BLACK_W/2} y={3 + 10} textAnchor="middle" fontSize={7}
|
||||||
|
fill={inKey || inChord ? 'rgba(220,220,230,0.9)' : 'rgba(110,110,120,0.7)'}>
|
||||||
|
{pct}%
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
{/* Note name near bottom of key */}
|
||||||
|
<text x={x + BLACK_W/2} y={3 + BLACK_H - 5} textAnchor="middle" fontSize={7}
|
||||||
|
fill={inKey || inChord ? 'rgba(210,210,220,0.85)' : 'rgba(110,110,120,0.6)'}>
|
||||||
|
{NOTES[pc]}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mini fretboard — guitar mode chroma view ─────────────────────────────────
|
||||||
|
const STRINGS = [
|
||||||
|
{ label: 'e', root: 4 },
|
||||||
|
{ label: 'B', root: 11 },
|
||||||
|
{ label: 'G', root: 7 },
|
||||||
|
{ label: 'D', root: 2 },
|
||||||
|
{ label: 'A', root: 9 },
|
||||||
|
{ label: 'E', root: 4 },
|
||||||
|
]
|
||||||
|
const MF_NUT_X = 22
|
||||||
|
const MF_OPEN_X = 10
|
||||||
|
const MF_FRET_W = 28
|
||||||
|
const MF_STR_H = 16
|
||||||
|
const MF_PAD_T = 16
|
||||||
|
const MF_PAD_B = 8
|
||||||
|
const MF_FRETS = 13 // frets 0–12
|
||||||
|
const MF_DOT_R = 6
|
||||||
|
const MF_W = MF_NUT_X + (MF_FRETS - 1) * MF_FRET_W + 10
|
||||||
|
const MF_H = MF_PAD_T + 5 * MF_STR_H + MF_PAD_B
|
||||||
|
|
||||||
|
const mfFretX = f => MF_NUT_X + (f - 0.5) * MF_FRET_W
|
||||||
|
const mfStringY = si => MF_PAD_T + si * MF_STR_H
|
||||||
|
|
||||||
|
function MiniFretboard({ values, keyNotes, chordNotes, monoColor = false }) {
|
||||||
|
const max = Math.max(...values, 0.01)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${MF_W} ${MF_H}`} width="100%" style={{ display: 'block' }}>
|
||||||
|
{/* Board background */}
|
||||||
|
<rect x={MF_NUT_X} y={MF_PAD_T - 5}
|
||||||
|
width={MF_W - MF_NUT_X - 6} height={5 * MF_STR_H + 10}
|
||||||
|
fill="#1a120b" rx={2} />
|
||||||
|
|
||||||
|
{/* Fret position dots */}
|
||||||
|
{[3, 5, 7, 9].map(f => (
|
||||||
|
<circle key={f} cx={mfFretX(f)} cy={MF_PAD_T + 2.5 * MF_STR_H} r={3} fill="#3a2a1a" />
|
||||||
|
))}
|
||||||
|
<circle cx={mfFretX(12)} cy={MF_PAD_T + 1.5 * MF_STR_H} r={3} fill="#3a2a1a" />
|
||||||
|
<circle cx={mfFretX(12)} cy={MF_PAD_T + 3.5 * MF_STR_H} r={3} fill="#3a2a1a" />
|
||||||
|
|
||||||
|
{/* Fret lines */}
|
||||||
|
{Array.from({ length: MF_FRETS - 1 }, (_, i) => i + 1).map(f => (
|
||||||
|
<line key={f}
|
||||||
|
x1={MF_NUT_X + f * MF_FRET_W} y1={MF_PAD_T - 5}
|
||||||
|
x2={MF_NUT_X + f * MF_FRET_W} y2={MF_PAD_T + 5 * MF_STR_H + 5}
|
||||||
|
stroke="#4a3a2a" strokeWidth={1} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Nut */}
|
||||||
|
<line x1={MF_NUT_X} y1={MF_PAD_T - 5} x2={MF_NUT_X} y2={MF_PAD_T + 5 * MF_STR_H + 5}
|
||||||
|
stroke="#c0b090" strokeWidth={3} />
|
||||||
|
|
||||||
|
{/* Strings */}
|
||||||
|
{STRINGS.map((_, si) => (
|
||||||
|
<line key={si}
|
||||||
|
x1={MF_OPEN_X - MF_DOT_R - 2} y1={mfStringY(si)}
|
||||||
|
x2={MF_W - 6} y2={mfStringY(si)}
|
||||||
|
stroke="#9ca3af"
|
||||||
|
strokeWidth={si < 2 ? 0.8 : si < 4 ? 1.2 : 1.8} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Fret numbers */}
|
||||||
|
{[3, 5, 7, 9, 12].map(f => (
|
||||||
|
<text key={f} x={mfFretX(f)} y={MF_PAD_T - 5}
|
||||||
|
textAnchor="middle" fontSize={8} fill="#6b7280">{f}</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* String labels */}
|
||||||
|
{STRINGS.map((s, si) => (
|
||||||
|
<text key={si} x={5} y={mfStringY(si) + 3.5}
|
||||||
|
textAnchor="middle" fontSize={9} fill="#6b7280">{s.label}</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Note dots — colored by energy */}
|
||||||
|
{STRINGS.flatMap((str, si) =>
|
||||||
|
Array.from({ length: MF_FRETS }, (_, fi) => {
|
||||||
|
const pc = (str.root + fi) % 12
|
||||||
|
const energy = values[pc] / max
|
||||||
|
const inChord = chordNotes?.has(pc)
|
||||||
|
const inKey = keyNotes?.has(pc)
|
||||||
|
if (!inChord && !inKey && energy < 0.35) return null
|
||||||
|
if ((inChord || inKey) && energy < 0.08) return null
|
||||||
|
|
||||||
|
const cx = fi === 0 ? MF_OPEN_X : mfFretX(fi)
|
||||||
|
const cy = mfStringY(si)
|
||||||
|
|
||||||
|
let fill, textFill
|
||||||
|
if (inChord) {
|
||||||
|
fill = `rgba(168,85,247,${0.3 + energy * 0.7})`
|
||||||
|
textFill = '#fff'
|
||||||
|
} else if (inKey) {
|
||||||
|
fill = monoColor ? `rgba(192,132,252,${0.2 + energy * 0.75})` : `rgba(245,158,11,${0.2 + energy * 0.75})`
|
||||||
|
textFill = monoColor ? '#fff' : 'rgba(0,0,0,0.85)'
|
||||||
|
} else {
|
||||||
|
fill = `rgba(100,100,120,${energy * 0.7})`
|
||||||
|
textFill = 'rgba(180,180,190,0.7)'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`${si}-${fi}`}>
|
||||||
|
{energy > 0.3 && (inChord || inKey) && (
|
||||||
|
<circle cx={cx} cy={cy} r={MF_DOT_R + 4}
|
||||||
|
fill={inChord ? 'rgba(168,85,247,0.25)' : monoColor ? 'rgba(192,132,252,0.2)' : 'rgba(245,158,11,0.2)'}
|
||||||
|
style={{ filter: 'blur(4px)' }} />
|
||||||
|
)}
|
||||||
|
<circle cx={cx} cy={cy} r={MF_DOT_R} fill={fill} />
|
||||||
|
<text x={cx} y={cy + 3.5} textAnchor="middle" fontSize={7} fontWeight="600" fill={textFill}>
|
||||||
|
{NOTES[pc]}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Oscilloscope strip ───────────────────────────────────────────────────────
|
||||||
|
const OSC_W = 600
|
||||||
|
const OSC_H = 110
|
||||||
|
|
||||||
|
function Oscilloscope({ waveform }) {
|
||||||
|
const { wave, rms, detectedFreq, detectedNote } = waveform || {}
|
||||||
|
const silent = !rms || rms < 0.005
|
||||||
|
|
||||||
|
// ── Note scroll history — last 5 distinct notes ───────────────────────────
|
||||||
|
const noteHistoryRef = useRef([]) // [{ note, freq, id }, ...] oldest first
|
||||||
|
const lastNoteRef = useRef(null)
|
||||||
|
const noteIdRef = useRef(0)
|
||||||
|
const lastDisplayRef = useRef(null) // last detected note shown in header — never flickers
|
||||||
|
if (detectedNote && detectedNote !== lastNoteRef.current) {
|
||||||
|
lastNoteRef.current = detectedNote
|
||||||
|
lastDisplayRef.current = { note: detectedNote, freq: detectedFreq }
|
||||||
|
noteHistoryRef.current.push({ note: detectedNote, freq: detectedFreq, id: noteIdRef.current++ })
|
||||||
|
if (noteHistoryRef.current.length > 5) noteHistoryRef.current.shift()
|
||||||
|
} else if (detectedFreq && detectedNote) {
|
||||||
|
lastDisplayRef.current = { note: detectedNote, freq: detectedFreq }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ghost waveform — holds the last clear-pitch shape, fades slowly ───────
|
||||||
|
const ghostRef = useRef({ path: '', fill: '', opacity: 0 })
|
||||||
|
if (detectedFreq) {
|
||||||
|
ghostRef.current = { path: '', fill: '', opacity: 1 } // will be filled below
|
||||||
|
} else {
|
||||||
|
ghostRef.current = { ...ghostRef.current, opacity: ghostRef.current.opacity * 0.97 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = '', sinePath = ''
|
||||||
|
if (wave?.length) {
|
||||||
|
const mid = OSC_H / 2
|
||||||
|
const waveAmp = Math.max(...wave.map(Math.abs), 0.001)
|
||||||
|
const gain = Math.min((OSC_H * 0.44) / waveAmp, OSC_H * 0.44)
|
||||||
|
|
||||||
|
const lo = Math.floor(wave.length / 4)
|
||||||
|
const hi = Math.floor(wave.length / 2)
|
||||||
|
let offset = lo
|
||||||
|
for (let i = lo; i < hi - 1; i++) {
|
||||||
|
if (wave[i] <= 0 && wave[i + 1] > 0) { offset = i; break }
|
||||||
|
}
|
||||||
|
const drawLen = Math.min(wave.length - offset, Math.floor(wave.length * 0.85))
|
||||||
|
const step = OSC_W / drawLen
|
||||||
|
|
||||||
|
path = Array.from({ length: drawLen }, (_, i) => {
|
||||||
|
const v = wave[offset + i]
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(mid - v * gain).toFixed(1)}`
|
||||||
|
}).join(' ')
|
||||||
|
|
||||||
|
// Capture ghost path when we have a clear pitch
|
||||||
|
if (detectedFreq) {
|
||||||
|
ghostRef.current.path = path
|
||||||
|
ghostRef.current.fill = path + ` L${OSC_W},${mid} L0,${mid} Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedFreq) {
|
||||||
|
const effectiveSR = 44100 / 8
|
||||||
|
const sineAmp = Math.min(waveAmp * gain * 0.55, OSC_H * 0.38)
|
||||||
|
sinePath = Array.from({ length: 300 }, (_, i) => {
|
||||||
|
const t = i / 299
|
||||||
|
const x = (t * OSC_W).toFixed(1)
|
||||||
|
const phase = ((offset + t * drawLen) / effectiveSR) * detectedFreq * Math.PI * 2
|
||||||
|
const y = (mid - Math.sin(phase) * sineAmp).toFixed(1)
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x},${y}`
|
||||||
|
}).join(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineColor = detectedFreq
|
||||||
|
? 'rgba(168,85,247,0.9)'
|
||||||
|
: silent ? 'rgba(50,50,60,0.8)' : 'rgba(100,200,140,0.75)'
|
||||||
|
|
||||||
|
const ghost = ghostRef.current
|
||||||
|
const ghostOp = ghost.opacity
|
||||||
|
const noteHistory = noteHistoryRef.current
|
||||||
|
const lastDisplay = lastDisplayRef.current
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<p className="text-xs text-gray-600 uppercase tracking-widest">Oscilloscope — raw mic input</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{lastDisplay && (
|
||||||
|
<>
|
||||||
|
<span className={`text-xs font-bold ${detectedFreq ? 'text-accent' : 'text-gray-500'}`}>{lastDisplay.note}</span>
|
||||||
|
<span className="text-xs text-gray-500 tabular-nums">{lastDisplay.freq.toFixed(1)} Hz</span>
|
||||||
|
<span className="text-xs text-gray-600 tabular-nums">{(1000 / lastDisplay.freq).toFixed(2)} ms / cycle</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{silent && <span className="text-xs text-gray-700">silence</span>}
|
||||||
|
<span className="text-xs text-gray-700 tabular-nums">rms {rms ? (rms * 100).toFixed(1) : '0.0'}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg viewBox={`0 0 ${OSC_W} ${OSC_H}`} width="100%" style={{ display: 'block' }}
|
||||||
|
className="rounded-lg bg-surface border border-border">
|
||||||
|
{/* Zero line */}
|
||||||
|
<line x1={0} y1={OSC_H / 2} x2={OSC_W} y2={OSC_H / 2}
|
||||||
|
stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} />
|
||||||
|
|
||||||
|
{/* Ghost waveform — previous clear-pitch shape fading out */}
|
||||||
|
{ghost.path && ghostOp > 0.04 && !detectedFreq && (
|
||||||
|
<>
|
||||||
|
<path d={ghost.fill} fill={`rgba(168,85,247,${(ghostOp * 0.06).toFixed(3)})`} />
|
||||||
|
<path d={ghost.path} fill="none"
|
||||||
|
stroke={`rgba(150,120,200,${(ghostOp * 0.35).toFixed(3)})`}
|
||||||
|
strokeWidth={0.8} strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fill body */}
|
||||||
|
{path && (
|
||||||
|
<path
|
||||||
|
d={`${path} L${OSC_W},${OSC_H / 2} L0,${OSC_H / 2} Z`}
|
||||||
|
fill={detectedFreq
|
||||||
|
? 'rgba(168,85,247,0.08)'
|
||||||
|
: silent ? 'none' : 'rgba(90,190,130,0.07)'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Waveform line */}
|
||||||
|
{path && <path d={path} fill="none" stroke={lineColor} strokeWidth={0.9}
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />}
|
||||||
|
{/* Sine overlay */}
|
||||||
|
{sinePath && <path d={sinePath} fill="none"
|
||||||
|
stroke="rgba(168,85,247,0.28)" strokeWidth={0.9}
|
||||||
|
strokeLinejoin="round" strokeDasharray="5 4" />}
|
||||||
|
|
||||||
|
{/* Scrolling note history — newest on right, slides left on each new note */}
|
||||||
|
{noteHistory.map((entry, i) => {
|
||||||
|
const age = noteHistory.length - 1 - i // 0 = newest
|
||||||
|
const x = OSC_W - 28 - age * 100
|
||||||
|
const op = (1 - age * 0.18).toFixed(2)
|
||||||
|
const isNew = age === 0
|
||||||
|
return (
|
||||||
|
<g key={entry.id}
|
||||||
|
style={{ transform: `translateX(${x}px)`, transition: 'transform 0.45s cubic-bezier(0.4,0,0.2,1)' }}>
|
||||||
|
<text x={0} y={OSC_H - 18} textAnchor="middle"
|
||||||
|
fontSize={isNew ? 13 : 11} fontWeight={isNew ? '700' : '400'}
|
||||||
|
fill={isNew ? `rgba(168,85,247,${op})` : `rgba(160,130,210,${op})`}>
|
||||||
|
{entry.note}
|
||||||
|
</text>
|
||||||
|
<text x={0} y={OSC_H - 7} textAnchor="middle" fontSize={7}
|
||||||
|
fill={`rgba(120,100,160,${(parseFloat(op) * 0.7).toFixed(2)})`}>
|
||||||
|
{entry.freq ? entry.freq.toFixed(0) : ''}Hz
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Frequency spectrum ───────────────────────────────────────────────────────
|
||||||
|
const SPEC_H = 130
|
||||||
|
const SPEC_F_MIN = 40
|
||||||
|
const SPEC_F_MAX = 4000
|
||||||
|
const SPEC_LOG = Math.log(SPEC_F_MAX / SPEC_F_MIN)
|
||||||
|
|
||||||
|
// Map a frequency in Hz to an x pixel position (log scale)
|
||||||
|
function specX(f, w) {
|
||||||
|
if (f <= SPEC_F_MIN) return 0
|
||||||
|
if (f >= SPEC_F_MAX) return w
|
||||||
|
return w * Math.log(f / SPEC_F_MIN) / SPEC_LOG
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEC_GRID = [
|
||||||
|
{ label: 'E2', freq: 82.4 },
|
||||||
|
{ label: 'C3', freq: 130.8 },
|
||||||
|
{ label: 'E3', freq: 164.8 },
|
||||||
|
{ label: 'A3', freq: 220 },
|
||||||
|
{ label: 'C4', freq: 261.6 },
|
||||||
|
{ label: 'E4', freq: 329.6 },
|
||||||
|
{ label: 'A4', freq: 440 },
|
||||||
|
{ label: 'C5', freq: 523.3 },
|
||||||
|
{ label: 'C6', freq: 1046.5},
|
||||||
|
{ label: 'C7', freq: 2093 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function SpectrumPanel({ spectrum, detectedFreq }) {
|
||||||
|
const W = OSC_W
|
||||||
|
const ghostRef = useRef(null)
|
||||||
|
const ghostFreqRef = useRef(null) // { freq, opacity }
|
||||||
|
|
||||||
|
// Ghost frequency lines — lock on detection, decay slowly when gone
|
||||||
|
if (detectedFreq) {
|
||||||
|
ghostFreqRef.current = { freq: detectedFreq, opacity: 1 }
|
||||||
|
} else if (ghostFreqRef.current) {
|
||||||
|
ghostFreqRef.current = { freq: ghostFreqRef.current.freq, opacity: ghostFreqRef.current.opacity * 0.97 }
|
||||||
|
}
|
||||||
|
const ghostFreq = ghostFreqRef.current?.opacity > 0.04 ? ghostFreqRef.current.freq : null
|
||||||
|
const ghostOpacity = ghostFreqRef.current?.opacity ?? 0
|
||||||
|
|
||||||
|
// Ghost: rises instantly with signal, decays very slowly — lingers as grey
|
||||||
|
if (spectrum?.length) {
|
||||||
|
if (!ghostRef.current) ghostRef.current = new Float32Array(spectrum.length)
|
||||||
|
const ghost = ghostRef.current
|
||||||
|
for (let i = 0; i < spectrum.length; i++) {
|
||||||
|
ghost[i] = spectrum[i] > ghost[i] ? spectrum[i] : ghost[i] * 0.988
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fillPath = '', strokePath = '', ghostFill = '', ghostStroke = ''
|
||||||
|
|
||||||
|
if (spectrum?.length) {
|
||||||
|
const n = spectrum.length
|
||||||
|
const pts = Array.from({ length: n }, (_, i) => {
|
||||||
|
const x = ((i / (n - 1)) * W).toFixed(1)
|
||||||
|
const y = (SPEC_H * (1 - spectrum[i])).toFixed(1)
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x},${y}`
|
||||||
|
}).join(' ')
|
||||||
|
strokePath = pts
|
||||||
|
fillPath = pts + ` L${W},${SPEC_H} L0,${SPEC_H} Z`
|
||||||
|
|
||||||
|
const ghost = ghostRef.current
|
||||||
|
if (ghost) {
|
||||||
|
const gpts = Array.from({ length: n }, (_, i) => {
|
||||||
|
const x = ((i / (n - 1)) * W).toFixed(1)
|
||||||
|
const y = (SPEC_H * (1 - ghost[i])).toFixed(1)
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x},${y}`
|
||||||
|
}).join(' ')
|
||||||
|
ghostStroke = gpts
|
||||||
|
ghostFill = gpts + ` L${W},${SPEC_H} L0,${SPEC_H} Z`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 uppercase tracking-widest mb-1">
|
||||||
|
Frequency spectrum — 40 Hz → 4 kHz (log scale)
|
||||||
|
</p>
|
||||||
|
<svg viewBox={`0 0 ${W} ${SPEC_H}`} width="100%" style={{ display: 'block' }}
|
||||||
|
className="rounded-lg bg-surface border border-border">
|
||||||
|
|
||||||
|
{/* Note grid lines */}
|
||||||
|
{SPEC_GRID.map(({ label, freq }) => {
|
||||||
|
const x = specX(freq, W).toFixed(1)
|
||||||
|
return (
|
||||||
|
<g key={label}>
|
||||||
|
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
|
||||||
|
stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
|
||||||
|
<text x={x} y={SPEC_H - 3} textAnchor="middle" fontSize={7.5}
|
||||||
|
fill="rgba(80,80,95,0.9)">{label}</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Ghost — slow-decaying grey residue from previous peaks */}
|
||||||
|
{ghostFill && (
|
||||||
|
<>
|
||||||
|
<path d={ghostFill} fill="rgba(120,120,130,0.08)" />
|
||||||
|
<path d={ghostStroke} fill="none" stroke="rgba(130,130,145,0.30)" strokeWidth={0.7} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live spectrum fill + stroke */}
|
||||||
|
{fillPath && (
|
||||||
|
<>
|
||||||
|
<path d={fillPath} fill="rgba(80,180,130,0.13)" />
|
||||||
|
<path d={strokePath} fill="none" stroke="rgba(90,200,145,0.55)" strokeWidth={0.8} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fundamental + harmonics */}
|
||||||
|
{ghostFreq && [1, 2, 3, 4, 5].map(h => {
|
||||||
|
const hf = ghostFreq * h
|
||||||
|
if (hf > SPEC_F_MAX) return null
|
||||||
|
const xNum = specX(hf, W)
|
||||||
|
const x = xNum.toFixed(1)
|
||||||
|
const midi = Math.round(12 * Math.log2(hf / 440) + 69)
|
||||||
|
const note = NOTES[((midi % 12) + 12) % 12]
|
||||||
|
const oct = Math.floor(midi / 12) - 1
|
||||||
|
// Place label left of line near the right edge, right of line elsewhere
|
||||||
|
const labelX = xNum > W - 40 ? xNum - 3 : xNum + 3
|
||||||
|
const anchor = xNum > W - 40 ? 'end' : 'start'
|
||||||
|
|
||||||
|
if (h === 1) {
|
||||||
|
const op = (0.9 * ghostOpacity).toFixed(3)
|
||||||
|
const textOp = (ghostOpacity * 0.95).toFixed(3)
|
||||||
|
return (
|
||||||
|
<g key={h}>
|
||||||
|
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
|
||||||
|
stroke={`rgba(168,85,247,${op})`} strokeWidth={1.2} />
|
||||||
|
<text x={labelX} y={10} textAnchor={anchor} fontSize={8} fontWeight="700"
|
||||||
|
fill={`rgba(168,85,247,${textOp})`}>{note}{oct}</text>
|
||||||
|
<text x={labelX} y={20} textAnchor={anchor} fontSize={7}
|
||||||
|
fill={`rgba(168,85,247,${(ghostOpacity * 0.55).toFixed(3)})`}>f</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const op = ((0.5 - (h - 2) * 0.1) * ghostOpacity).toFixed(3)
|
||||||
|
return (
|
||||||
|
<g key={h}>
|
||||||
|
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
|
||||||
|
stroke={`rgba(168,85,247,${op})`} strokeWidth={0.7} strokeDasharray="3 4" />
|
||||||
|
<text x={labelX} y={10} textAnchor={anchor} fontSize={7.5}
|
||||||
|
fill={`rgba(168,85,247,${op})`}>{note}{oct}</text>
|
||||||
|
<text x={labelX} y={19} textAnchor={anchor} fontSize={7}
|
||||||
|
fill={`rgba(168,85,247,${(parseFloat(op) * 0.7).toFixed(3)})`}>{h}f</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
export default function DebugView({ chroma, chordCandidates, noteAnalysis, waveform, keyInfo, currentChord, instrument = 'guitar', monoColor = false }) {
|
||||||
|
const keyPCs = new Set(keyInfo ? getScale(keyInfo.root, keyInfo.mode).map(n => NOTES.indexOf(n)) : [])
|
||||||
|
const chordPCs = new Set(currentChord ? getChordTones(currentChord).map(n => NOTES.indexOf(n)) : [])
|
||||||
|
|
||||||
|
const chromaArr = chroma ? [...chroma] : new Array(12).fill(0)
|
||||||
|
const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0)
|
||||||
|
const topKeys = noteAnalysis ? noteAnalysis.topKeys : []
|
||||||
|
const totalNotes = noteAnalysis?.total ?? 0
|
||||||
|
const sessionSecs = noteAnalysis?.sessionSecs ?? 0
|
||||||
|
const sessionLabel = sessionSecs >= 60
|
||||||
|
? `${Math.floor(sessionSecs / 60)}m ${sessionSecs % 60}s`
|
||||||
|
: `${sessionSecs}s`
|
||||||
|
const topScore = chordCandidates[0]?.score ?? 1
|
||||||
|
const topKeyScore = topKeys[0]?.score ?? 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* ── Live chroma visualization (instrument-synced) ── */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Live chroma — what the engine hears right now</p>
|
||||||
|
{instrument === 'guitar'
|
||||||
|
? <MiniFretboard values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} monoColor={monoColor} />
|
||||||
|
: <PianoSVG values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} keyH={90} monoColor={monoColor} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Bottom three columns ── */}
|
||||||
|
<div className="grid grid-cols-[2fr_1.5fr_1fr] gap-5">
|
||||||
|
|
||||||
|
{/* Col 1: Chord candidates */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Chord candidates</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{chordCandidates.length === 0 && (
|
||||||
|
<p className="text-gray-700 text-xs">No signal detected</p>
|
||||||
|
)}
|
||||||
|
{chordCandidates.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={c.name}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg ${
|
||||||
|
i === 0 ? 'bg-accent/10 border border-accent/25' : 'border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-gray-600 w-3 shrink-0">{i + 1}</span>
|
||||||
|
<span className={`text-sm font-bold w-14 shrink-0 ${i === 0 ? 'text-white' : 'text-gray-400'}`}>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-300 ${i === 0 ? 'bg-accent' : 'bg-gray-600'}`}
|
||||||
|
style={{ width: `${(c.score / topScore) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 w-8 text-right tabular-nums">{c.score.toFixed(2)}</span>
|
||||||
|
<div className="flex gap-1 w-14 justify-end">
|
||||||
|
{c.diatonic && <span className="text-[9px] px-1 rounded bg-green-900/50 text-green-400">key</span>}
|
||||||
|
{c.bassBonus > 0 && <span className="text-[9px] px-1 rounded bg-blue-900/50 text-blue-400">bass</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 2: Note history piano with % labels */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<p className="text-xs text-gray-600 uppercase tracking-widest">Note history</p>
|
||||||
|
{totalNotes > 0 && (
|
||||||
|
<span className="text-[10px] text-gray-600 tabular-nums">
|
||||||
|
{totalNotes.toLocaleString()} notes · {sessionLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PianoSVG values={histFreq} keyNotes={keyPCs} chordNotes={chordPCs} keyH={70} showPct={true} monoColor={monoColor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 3: Key candidates */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Key match scores</p>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{topKeys.length === 0 && (
|
||||||
|
<p className="text-gray-700 text-xs">Not enough history</p>
|
||||||
|
)}
|
||||||
|
{topKeys.map((k, i) => (
|
||||||
|
<div key={`${k.root}-${k.mode}`} className="flex items-center gap-2">
|
||||||
|
<span className={`text-xs w-16 shrink-0 ${i === 0 ? 'text-white font-semibold' : 'text-gray-500'}`}>
|
||||||
|
{k.root} {k.mode === 'major' ? 'maj' : 'min'}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-1 bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${i === 0 ? 'bg-amber-400' : 'bg-gray-600'}`}
|
||||||
|
style={{ width: `${Math.max(0, (k.score / topKeyScore) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-600 tabular-nums w-8 text-right">{k.score.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Oscilloscope + spectrum ── */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Oscilloscope waveform={waveform} />
|
||||||
|
<SpectrumPanel spectrum={waveform?.spectrum} detectedFreq={waveform?.detectedFreq} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useRef, useEffect } from 'react'
|
||||||
|
|
||||||
|
// Map a frequency (Hz) to a bin index in the 256-bin log spectrum (40–4000 Hz)
|
||||||
|
function freqToBin(freq) {
|
||||||
|
return Math.round(255 * Math.log(freq / 40) / Math.log(4000 / 40))
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandMax(spectrum, lo, hi) {
|
||||||
|
if (!spectrum) return 0
|
||||||
|
const a = freqToBin(lo)
|
||||||
|
const b = Math.min(freqToBin(hi), spectrum.length - 1)
|
||||||
|
let max = 0
|
||||||
|
for (let i = a; i <= b; i++) if (spectrum[i] > max) max = spectrum[i]
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
|
const BANDS = [
|
||||||
|
{ label: 'Kick', lo: 40, hi: 120, color: '#ef4444' },
|
||||||
|
{ label: 'Snare', lo: 120, hi: 300, color: '#f59e0b' },
|
||||||
|
{ label: 'Mid', lo: 300, hi: 1000, color: '#22c55e' },
|
||||||
|
{ label: 'Presence', lo: 1000, hi: 4000, color: '#60a5fa' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TIMELINE_MS = 4000 // onset timeline window
|
||||||
|
const RMS_HISTORY = 180 // ~3s at 60fps
|
||||||
|
|
||||||
|
export default function DrumView({ waveform, bpm }) {
|
||||||
|
const rmsHistRef = useRef([])
|
||||||
|
const beatCanvasRef = useRef(null)
|
||||||
|
const rmsCanvasRef = useRef(null)
|
||||||
|
|
||||||
|
const spectrum = waveform?.spectrum ?? null
|
||||||
|
const bandLevels = BANDS.map(b => bandMax(spectrum, b.lo, b.hi))
|
||||||
|
|
||||||
|
// Accumulate RMS history
|
||||||
|
useEffect(() => {
|
||||||
|
if (waveform == null) return
|
||||||
|
const h = rmsHistRef.current
|
||||||
|
h.push(Math.min(waveform.rms * 10, 1))
|
||||||
|
if (h.length > RMS_HISTORY) h.shift()
|
||||||
|
}, [waveform])
|
||||||
|
|
||||||
|
// Draw onset / beat timeline
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = beatCanvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const W = canvas.width, H = canvas.height
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0a0a0a'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
|
const onsets = waveform?.onsets ?? []
|
||||||
|
const now = performance.now()
|
||||||
|
|
||||||
|
// Beat grid aligned to the most recent onset
|
||||||
|
if (bpm) {
|
||||||
|
const beatMs = 60000 / bpm
|
||||||
|
const numBeats = Math.ceil(TIMELINE_MS / beatMs) + 1
|
||||||
|
const latest = onsets[onsets.length - 1]
|
||||||
|
const phase = latest != null ? (now - latest) % beatMs : 0
|
||||||
|
for (let b = 0; b <= numBeats; b++) {
|
||||||
|
const ageMs = b * beatMs - phase
|
||||||
|
if (ageMs < 0 || ageMs > TIMELINE_MS) continue
|
||||||
|
const x = W * (1 - ageMs / TIMELINE_MS)
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.07)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Onset dots + vertical tails
|
||||||
|
const recent = onsets.filter(t => now - t <= TIMELINE_MS)
|
||||||
|
for (const t of recent) {
|
||||||
|
const age = now - t
|
||||||
|
const x = W * (1 - age / TIMELINE_MS)
|
||||||
|
const alpha = Math.pow(1 - age / TIMELINE_MS, 0.4)
|
||||||
|
ctx.strokeStyle = `rgba(168,85,247,${(alpha * 0.35).toFixed(2)})`
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
ctx.fillStyle = `rgba(168,85,247,${alpha.toFixed(2)})`
|
||||||
|
ctx.beginPath(); ctx.arc(x, H / 2, 5, 0, Math.PI * 2); ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Now" edge
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.18)'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath(); ctx.moveTo(W - 1, 0); ctx.lineTo(W - 1, H); ctx.stroke()
|
||||||
|
}, [waveform, bpm])
|
||||||
|
|
||||||
|
// Draw RMS envelope
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = rmsCanvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const W = canvas.width, H = canvas.height
|
||||||
|
const h = rmsHistRef.current
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0a0a0a'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
if (h.length < 2) return
|
||||||
|
|
||||||
|
const barW = W / RMS_HISTORY
|
||||||
|
for (let i = 0; i < h.length; i++) {
|
||||||
|
const x = W * (i / RMS_HISTORY)
|
||||||
|
const barH = h[i] * H
|
||||||
|
const v = Math.round(h[i] * 160 + 60)
|
||||||
|
ctx.fillStyle = `rgb(${v},30,${v})`
|
||||||
|
ctx.fillRect(x, H - barH, Math.max(barW - 0.5, 1), barH)
|
||||||
|
}
|
||||||
|
}, [waveform])
|
||||||
|
|
||||||
|
const noData = !waveform
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* Band meters */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">Frequency Bands</p>
|
||||||
|
<div className="flex gap-3" style={{ height: 96 }}>
|
||||||
|
{BANDS.map((b, i) => (
|
||||||
|
<div key={b.label} className="flex flex-col items-center gap-1 flex-1">
|
||||||
|
<div className="flex-1 w-full bg-gray-900 rounded-sm relative overflow-hidden">
|
||||||
|
{noData ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-[9px] text-gray-700 font-mono">—</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 rounded-sm"
|
||||||
|
style={{
|
||||||
|
height: `${bandLevels[i] * 100}%`,
|
||||||
|
backgroundColor: b.color,
|
||||||
|
transition: 'height 60ms linear',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-gray-500 uppercase">{b.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Onset timeline */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">
|
||||||
|
Onset Timeline{bpm ? ` · ${bpm} BPM` : ''}
|
||||||
|
<span className="ml-2 text-gray-700 normal-case">← 4 seconds</span>
|
||||||
|
</p>
|
||||||
|
<canvas
|
||||||
|
ref={beatCanvasRef}
|
||||||
|
width={800}
|
||||||
|
height={56}
|
||||||
|
className="w-full rounded"
|
||||||
|
style={{ height: 56 }}
|
||||||
|
/>
|
||||||
|
{noData && (
|
||||||
|
<p className="text-xs text-gray-700 font-mono mt-1 text-center">Start listening to see hits</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume envelope */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">
|
||||||
|
Volume Envelope
|
||||||
|
<span className="ml-2 text-gray-700 normal-case">← ~3 seconds</span>
|
||||||
|
</p>
|
||||||
|
<canvas
|
||||||
|
ref={rmsCanvasRef}
|
||||||
|
width={800}
|
||||||
|
height={56}
|
||||||
|
className="w-full rounded"
|
||||||
|
style={{ height: 56 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -30,14 +30,14 @@ const fretX = f => NUT_X + (f - 0.5) * FRET_W
|
|||||||
// y centre of string si (0 = high e, 5 = low E)
|
// y centre of string si (0 = high e, 5 = low E)
|
||||||
const stringY = si => PAD_T + si * STRING_H
|
const stringY = si => PAD_T + si * STRING_H
|
||||||
|
|
||||||
function noteColor(isChordTone, isPenta, isScale) {
|
function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
||||||
if (isChordTone) return { fill: '#f59e0b', text: '#000' } // amber
|
if (isChordTone) return { fill: '#a855f7', text: '#fff' }
|
||||||
if (isPenta) return { fill: '#a855f7', text: '#fff' } // purple
|
if (isPenta) return mono ? { fill: '#c084fc', text: '#1e1b4b' } : { fill: '#f59e0b', text: '#000' }
|
||||||
if (isScale) return { fill: '#374151', text: '#d1d5db' } // grey
|
if (isScale) return mono ? { fill: '#e9d5ff', text: '#581c87' } : { fill: '#374151', text: '#d1d5db' }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false }) {
|
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false }) {
|
||||||
const { root, mode } = keyInfo ?? {}
|
const { root, mode } = keyInfo ?? {}
|
||||||
|
|
||||||
if (!root) return null
|
if (!root) return null
|
||||||
@@ -57,11 +57,12 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
|||||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div>
|
||||||
<svg
|
<svg
|
||||||
width={BOARD_W}
|
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||||
height={BOARD_H}
|
width="100%"
|
||||||
style={{ display: 'block', minWidth: BOARD_W }}
|
height="auto"
|
||||||
|
style={{ display: 'block' }}
|
||||||
>
|
>
|
||||||
{/* Fretboard background */}
|
{/* Fretboard background */}
|
||||||
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
|
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
|
||||||
@@ -119,7 +120,7 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
|||||||
{STRINGS.flatMap((str, si) =>
|
{STRINGS.flatMap((str, si) =>
|
||||||
Array.from({ length: NUM_FRETS }, (_, fi) => {
|
Array.from({ length: NUM_FRETS }, (_, fi) => {
|
||||||
const pc = (str.root + fi) % 12
|
const pc = (str.root + fi) % 12
|
||||||
const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc))
|
const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc), monoColor)
|
||||||
if (!color) return null
|
if (!color) return null
|
||||||
|
|
||||||
const cx = fi === 0 ? OPEN_X : fretX(fi)
|
const cx = fi === 0 ? OPEN_X : fretX(fi)
|
||||||
@@ -145,9 +146,9 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
||||||
<span><span className="text-amber-400">●</span> Chord tone</span>
|
<span><span className="text-accent">●</span> Chord tone</span>
|
||||||
<span><span className="text-accent">●</span> Pentatonic</span>
|
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||||
<span><span className="text-gray-500">●</span> Scale</span>
|
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { getPentatonicScale, getFullScale, getChordTones, NOTES } from '../lib/theory'
|
||||||
|
|
||||||
|
// White keys in order within an octave, mapped to pitch class
|
||||||
|
const WHITE_KEYS = [
|
||||||
|
{ pc: 0, label: 'C' },
|
||||||
|
{ pc: 2, label: 'D' },
|
||||||
|
{ pc: 4, label: 'E' },
|
||||||
|
{ pc: 5, label: 'F' },
|
||||||
|
{ pc: 7, label: 'G' },
|
||||||
|
{ pc: 9, label: 'A' },
|
||||||
|
{ pc: 11, label: 'B' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Black keys: position (in white-key units from left of octave) and pitch class
|
||||||
|
const BLACK_KEYS = [
|
||||||
|
{ pc: 1, offset: 0.7 }, // C#
|
||||||
|
{ pc: 3, offset: 1.7 }, // D#
|
||||||
|
{ pc: 6, offset: 3.7 }, // F#
|
||||||
|
{ pc: 8, offset: 4.7 }, // G#
|
||||||
|
{ pc: 10, offset: 5.7 }, // A#
|
||||||
|
]
|
||||||
|
|
||||||
|
const OCTAVES = 2 // number of octaves shown
|
||||||
|
const KEY_W = 40 // white key width
|
||||||
|
const KEY_H = 130 // white key height
|
||||||
|
const BLACK_W = 26 // black key width
|
||||||
|
const BLACK_H = 82 // black key height
|
||||||
|
const LABEL_Y = KEY_H - 10 // y of note label on white key
|
||||||
|
const BLACK_LABEL_Y = BLACK_H - 8
|
||||||
|
|
||||||
|
function keyColor(isChordTone, isPenta, isScale, isBlack, mono = false) {
|
||||||
|
if (isChordTone) return { fill: '#a855f7', text: '#fff' }
|
||||||
|
if (isPenta) return mono ? { fill: '#c084fc', text: '#1e1b4b' } : { fill: '#f59e0b', text: '#000' }
|
||||||
|
if (isScale) return mono ? { fill: '#e9d5ff', text: '#581c87' } : { fill: '#374151', text: '#d1d5db' }
|
||||||
|
return isBlack
|
||||||
|
? { fill: '#1f1f1f', text: '#6b7280' }
|
||||||
|
: { fill: '#f5f5f5', text: '#6b7280' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
||||||
|
const { root, mode } = keyInfo ?? {}
|
||||||
|
if (!root) return null
|
||||||
|
|
||||||
|
const pentaSet = new Set(getPentatonicScale(root, mode).map(n => NOTES.indexOf(n)))
|
||||||
|
const scaleSet = new Set(getFullScale(root, mode).map(n => NOTES.indexOf(n)))
|
||||||
|
const chordSet = currentChord
|
||||||
|
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
||||||
|
: new Set()
|
||||||
|
|
||||||
|
const totalWhite = WHITE_KEYS.length * OCTAVES
|
||||||
|
const svgW = totalWhite * KEY_W + 2
|
||||||
|
const svgH = KEY_H + 20 // +20 for octave labels
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||||
|
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||||
|
Piano — {root} {mode}
|
||||||
|
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<svg viewBox={`0 0 ${svgW} ${svgH}`} width="100%" height="auto" style={{ display: 'block' }}>
|
||||||
|
|
||||||
|
{/* White keys */}
|
||||||
|
{Array.from({ length: OCTAVES }, (_, oct) =>
|
||||||
|
WHITE_KEYS.map((k, wi) => {
|
||||||
|
const x = (oct * WHITE_KEYS.length + wi) * KEY_W + 1
|
||||||
|
const isChordTone = chordSet.has(k.pc)
|
||||||
|
const isPenta = pentaSet.has(k.pc)
|
||||||
|
const isScale = scaleSet.has(k.pc)
|
||||||
|
const { fill, text } = keyColor(isChordTone, isPenta, isScale, false, monoColor)
|
||||||
|
return (
|
||||||
|
<g key={`w-${oct}-${wi}`}>
|
||||||
|
<rect
|
||||||
|
x={x} y={0}
|
||||||
|
width={KEY_W - 1} height={KEY_H}
|
||||||
|
fill={fill}
|
||||||
|
rx={3}
|
||||||
|
stroke="#2a2a2a"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
{(isChordTone || isPenta || isScale) && (
|
||||||
|
<text
|
||||||
|
x={x + KEY_W / 2} y={LABEL_Y}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fontWeight="600"
|
||||||
|
fill={text}
|
||||||
|
>
|
||||||
|
{k.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Black keys (drawn on top) */}
|
||||||
|
{Array.from({ length: OCTAVES }, (_, oct) =>
|
||||||
|
BLACK_KEYS.map((k, bi) => {
|
||||||
|
const x = (oct * WHITE_KEYS.length + k.offset) * KEY_W + 1
|
||||||
|
const isChordTone = chordSet.has(k.pc)
|
||||||
|
const isPenta = pentaSet.has(k.pc)
|
||||||
|
const isScale = scaleSet.has(k.pc)
|
||||||
|
const { fill, text } = keyColor(isChordTone, isPenta, isScale, true, monoColor)
|
||||||
|
return (
|
||||||
|
<g key={`b-${oct}-${bi}`}>
|
||||||
|
<rect
|
||||||
|
x={x} y={0}
|
||||||
|
width={BLACK_W} height={BLACK_H}
|
||||||
|
fill={fill}
|
||||||
|
rx={2}
|
||||||
|
stroke="#111"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
{(isChordTone || isPenta || isScale) && (
|
||||||
|
<text
|
||||||
|
x={x + BLACK_W / 2} y={BLACK_LABEL_Y}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fontWeight="600"
|
||||||
|
fill={text}
|
||||||
|
>
|
||||||
|
{NOTES[k.pc]}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
||||||
|
<span><span className="text-accent">●</span> Chord tone</span>
|
||||||
|
<span><span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span> Pentatonic</span>
|
||||||
|
<span><span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span> Scale</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useRef, useEffect } from 'react'
|
||||||
|
import { toRomanNumeral } from '../lib/theory'
|
||||||
|
|
||||||
|
const HISTORY_SHOWN = 8
|
||||||
|
|
||||||
|
function findLoopPosition(chordHistory, progression) {
|
||||||
|
if (!progression?.length || !chordHistory.length) return -1
|
||||||
|
const last = chordHistory[chordHistory.length - 1]
|
||||||
|
for (let p = progression.length - 1; p >= 0; p--) {
|
||||||
|
if (progression[p] !== last) continue
|
||||||
|
let match = true
|
||||||
|
for (let i = 1; i < Math.min(p + 1, chordHistory.length); i++) {
|
||||||
|
if (progression[p - i] !== chordHistory[chordHistory.length - 1 - i]) { match = false; break }
|
||||||
|
}
|
||||||
|
if (match) return p
|
||||||
|
}
|
||||||
|
return progression.indexOf(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord }) {
|
||||||
|
const { root, mode, confidence } = keyInfo ?? {}
|
||||||
|
|
||||||
|
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
||||||
|
const current = visible[visible.length - 1]
|
||||||
|
const loopPos = findLoopPosition(chordHistory, detectedProgression)
|
||||||
|
const currentRN = root && current ? toRomanNumeral(current, root, mode) : ''
|
||||||
|
|
||||||
|
const currentRef = useRef(null)
|
||||||
|
const prevChord = useRef(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (current && current !== prevChord.current && currentRef.current) {
|
||||||
|
currentRef.current.animate(
|
||||||
|
[{ opacity: 0, transform: 'scale(0.85)' }, { opacity: 1, transform: 'scale(1)' }],
|
||||||
|
{ duration: 200, easing: 'ease-out', fill: 'forwards' }
|
||||||
|
)
|
||||||
|
prevChord.current = current
|
||||||
|
}
|
||||||
|
}, [current])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-panel border border-border rounded-2xl p-4 mb-3 flex flex-col-reverse lg:flex-row gap-4">
|
||||||
|
|
||||||
|
{/* ── Left: key + chord history + loop ── */}
|
||||||
|
<div className="w-full lg:w-[70%] min-w-0 flex flex-col gap-2">
|
||||||
|
|
||||||
|
{/* Key + history on one row */}
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="shrink-0 flex items-baseline gap-1.5">
|
||||||
|
{root ? (
|
||||||
|
<>
|
||||||
|
<span className="text-2xl font-bold text-accent">{root}</span>
|
||||||
|
<span className="text-gray-400 text-sm">{mode}</span>
|
||||||
|
{confidence && (
|
||||||
|
<span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-sm">Detecting key…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border shrink-0" />
|
||||||
|
|
||||||
|
{!chordHistory.length ? (
|
||||||
|
<p className="text-gray-600 text-sm">Start listening…</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-end gap-1 overflow-x-auto pb-1">
|
||||||
|
{visible.map((chord, i) => {
|
||||||
|
const isCurrent = i === visible.length - 1
|
||||||
|
const age = visible.length - 1 - i
|
||||||
|
const opacity = Math.max(0.25, 1 - age * 0.09)
|
||||||
|
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
ref={isCurrent ? currentRef : null}
|
||||||
|
style={{ opacity }}
|
||||||
|
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 ${
|
||||||
|
isCurrent
|
||||||
|
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20'
|
||||||
|
: 'border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`font-black leading-none tracking-tight ${
|
||||||
|
isCurrent ? 'text-3xl text-accent' : 'text-xl text-gray-200'
|
||||||
|
}`}>
|
||||||
|
{chord}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-semibold mt-0.5 ${
|
||||||
|
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{rn || '\u00A0'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loop */}
|
||||||
|
{detectedProgression && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className="text-xs text-gray-500">♻</span>
|
||||||
|
{detectedProgression.map((chord, i) => {
|
||||||
|
const isActive = i === loopPos
|
||||||
|
const rn = root ? toRomanNumeral(chord, root, mode) : chord
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex flex-col items-center px-2 py-0.5 rounded-lg border transition-all duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-accent/20 border-accent shadow-[0_0_10px_rgba(168,85,247,0.3)]'
|
||||||
|
: 'bg-border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`text-sm font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-300'}`}>
|
||||||
|
{chord}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${isActive ? 'text-amber-400' : 'text-gray-600'}`}>{rn}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<span className="text-gray-600 text-xs">→ loop</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Divider ── */}
|
||||||
|
<div className="hidden lg:block w-px bg-border shrink-0" />
|
||||||
|
|
||||||
|
{/* ── Right: big chord ── */}
|
||||||
|
<div className="flex w-full lg:w-[30%] flex-col items-center justify-center gap-1 py-2 lg:py-0">
|
||||||
|
{current ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-gray-600 uppercase tracking-widest">Now Playing</p>
|
||||||
|
<div className="text-6xl font-black text-amber-400 leading-none">{current}</div>
|
||||||
|
<div className="text-sm text-gray-500">{currentRN}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 text-xs text-center">Play a chord</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { getSuggestedProgressions, getChordsInKey, toRomanNumeral, NOTES, NOTES_FLAT } from '../lib/theory'
|
||||||
|
|
||||||
|
// ─── Mood map ─────────────────────────────────────────────────────────────────
|
||||||
|
const MOOD = {
|
||||||
|
'I': 'Resolved', 'i': 'Settled',
|
||||||
|
'II': 'Lifted', 'ii': 'Yearning',
|
||||||
|
'III': 'Hopeful', 'iii': 'Tender',
|
||||||
|
'IV': 'Uplifting', 'iv': 'Longing',
|
||||||
|
'V': 'Tense', 'v': 'Unsettled',
|
||||||
|
'VI': 'Bright', 'vi': 'Melancholic',
|
||||||
|
'VII': 'Driving', 'vii': 'Uneasy',
|
||||||
|
'♭VII': 'Bluesy', 'bVII': 'Bluesy',
|
||||||
|
'♭VI': 'Dramatic', 'bVI': 'Dramatic',
|
||||||
|
'♭III': 'Epic', '♭II': 'Mysterious',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMood(rn) {
|
||||||
|
return MOOD[rn] ?? MOOD[rn?.replace(/[0-9]/g, '')] ?? 'Adventurous'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Genre accent colors ──────────────────────────────────────────────────────
|
||||||
|
const GENRE_COLOR = {
|
||||||
|
'Pop': 'text-pink-400',
|
||||||
|
'Blues': 'text-blue-400',
|
||||||
|
'Folk': 'text-green-400',
|
||||||
|
'Jazz': 'text-yellow-400',
|
||||||
|
'Rock': 'text-red-400',
|
||||||
|
"'50s": 'text-orange-400',
|
||||||
|
'Flamenco': 'text-rose-400',
|
||||||
|
'Circle ↑': 'text-cyan-400',
|
||||||
|
'Circle ↓': 'text-teal-400',
|
||||||
|
'Relative': 'text-violet-400',
|
||||||
|
'Thirds': 'text-indigo-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function noteIndex(note) {
|
||||||
|
const i = NOTES.indexOf(note)
|
||||||
|
return i >= 0 ? i : NOTES_FLAT.indexOf(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chordRoot(chord) {
|
||||||
|
const m = chord.match(/^([A-G][b#]?)/)
|
||||||
|
return m ? m[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Circle-of-fifths padding ─────────────────────────────────────────────────
|
||||||
|
// Moves tried in order to fill up to 6 total suggestions:
|
||||||
|
// +7 perfect 5th up (dominant direction — most common resolution)
|
||||||
|
// +5 perfect 4th up (subdominant direction)
|
||||||
|
// +9 major/minor 6th (relative minor/major feel)
|
||||||
|
// +3 minor 3rd up (mediant movement, dark → light)
|
||||||
|
const COF_MOVES = [
|
||||||
|
{ semitones: 7, genre: 'Circle ↑' },
|
||||||
|
{ semitones: 5, genre: 'Circle ↓' },
|
||||||
|
{ semitones: 9, genre: 'Relative' },
|
||||||
|
{ semitones: 3, genre: 'Thirds' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function cofSuggestions(currentChord, root, mode, exclude) {
|
||||||
|
const rootPc = noteIndex(chordRoot(currentChord) ?? '')
|
||||||
|
if (rootPc < 0) return []
|
||||||
|
|
||||||
|
const diatonic = getChordsInKey(root, mode)
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
for (const { semitones, genre } of COF_MOVES) {
|
||||||
|
const targetPc = ((rootPc + semitones) % 12 + 12) % 12
|
||||||
|
const match = diatonic.find(c => {
|
||||||
|
const r = chordRoot(c)
|
||||||
|
return r !== null && noteIndex(r) === targetPc
|
||||||
|
})
|
||||||
|
if (!match || exclude.has(match)) continue
|
||||||
|
const rn = toRomanNumeral(match, root, mode)
|
||||||
|
results.push({ genre, chord: match, rn, mood: getMood(rn) })
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main suggestion builder ──────────────────────────────────────────────────
|
||||||
|
function buildSuggestions(currentChord, root, mode) {
|
||||||
|
if (!currentChord || !root) return []
|
||||||
|
|
||||||
|
const genreSuggestions = getSuggestedProgressions(root, mode)
|
||||||
|
.map(prog => {
|
||||||
|
const idx = prog.chords.indexOf(currentChord)
|
||||||
|
if (idx < 0) return null
|
||||||
|
const next = (n) => prog.chords[(idx + n) % prog.chords.length]
|
||||||
|
const rnAt = (n) => prog.rn[(idx + n) % prog.chords.length]
|
||||||
|
const c1 = next(1), c2 = next(2), c3 = next(3)
|
||||||
|
const chord2 = c2 !== c1 ? c2 : null
|
||||||
|
const chord3 = chord2 && c3 !== c2 && c3 !== c1 ? c3 : null
|
||||||
|
return {
|
||||||
|
genre: prog.genre,
|
||||||
|
chord: c1, rn: rnAt(1), mood: getMood(rnAt(1)),
|
||||||
|
chord2, rn2: chord2 ? rnAt(2) : null, mood2: chord2 ? getMood(rnAt(2)) : null,
|
||||||
|
chord3, rn3: chord3 ? rnAt(3) : null, mood3: chord3 ? getMood(rnAt(3)) : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (genreSuggestions.length >= 4) return genreSuggestions.slice(0, 5)
|
||||||
|
|
||||||
|
// Pad with circle-of-fifths moves not already covered
|
||||||
|
const used = new Set(genreSuggestions.map(s => s.chord))
|
||||||
|
const padded = cofSuggestions(currentChord, root, mode, used)
|
||||||
|
|
||||||
|
return [...genreSuggestions, ...padded].slice(0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
export default function ProgressionSuggestions({ keyInfo, currentChord }) {
|
||||||
|
const { root, mode } = keyInfo ?? {}
|
||||||
|
const [suggestions, setSuggestions] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSuggestions(buildSuggestions(currentChord, root, mode))
|
||||||
|
}, [currentChord, root, mode])
|
||||||
|
|
||||||
|
if (!root) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-panel border border-border rounded-2xl p-3 flex flex-col h-full gap-1.5 overflow-hidden">
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-widest shrink-0">
|
||||||
|
Suggested Progression
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!currentChord || suggestions.length === 0 ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-gray-600 text-sm text-center">
|
||||||
|
{currentChord ? 'No suggestions' : 'Play a chord…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col justify-start gap-1 overflow-y-auto min-h-0">
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`${s.genre}-${i}`}
|
||||||
|
className="flex items-center gap-2 px-2.5 py-1.5 rounded-xl border border-border bg-surface/40 hover:border-gray-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<span className={`text-xs font-bold w-12 shrink-0 ${GENRE_COLOR[s.genre] ?? 'text-gray-400'}`}>
|
||||||
|
{s.genre}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-lg font-black text-white leading-none">{s.chord}</div>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
|
<span className="text-xs font-semibold text-amber-400">{s.rn}</span>
|
||||||
|
<span className="text-gray-700 text-xs">·</span>
|
||||||
|
<span className="text-xs text-gray-500 truncate">{s.mood}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{s.chord2 && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-600 text-xs shrink-0">|</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-lg font-black text-white/70 leading-none">{s.chord2}</div>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
|
<span className="text-xs font-semibold text-amber-400/70">{s.rn2}</span>
|
||||||
|
<span className="text-gray-700 text-xs">·</span>
|
||||||
|
<span className="text-xs text-gray-500 truncate">{s.mood2}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{s.chord3 && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-600 text-xs shrink-0">|</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-lg font-black text-white/50 leading-none">{s.chord3}</div>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
|
<span className="text-xs font-semibold text-amber-400/50">{s.rn3}</span>
|
||||||
|
<span className="text-gray-700 text-xs">·</span>
|
||||||
|
<span className="text-xs text-gray-500 truncate">{s.mood3}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useRef, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const SETTINGS = [
|
||||||
|
{
|
||||||
|
section: 'Chord Detection',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'chromaSmooth',
|
||||||
|
label: 'Chroma Smoothing',
|
||||||
|
min: 1, max: 20, step: 1,
|
||||||
|
desc: 'Frames to average for chord chroma. More = smoother but slower to react to chord changes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chordVoteThreshold',
|
||||||
|
label: 'Chord Vote Threshold',
|
||||||
|
min: 1, max: 8, step: 1,
|
||||||
|
desc: 'Consecutive identical detections required to confirm a chord. Higher = more stable, slower.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chordMinScore',
|
||||||
|
label: 'Chord Min Score',
|
||||||
|
min: 0.10, max: 0.80, step: 0.01,
|
||||||
|
desc: 'Minimum coverage score to accept a chord match. Lower = more chord types detected (may add false positives).',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: 'Key Detection',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'noteHistorySize',
|
||||||
|
label: 'Note History Size',
|
||||||
|
min: 100, max: 12000, step: 100,
|
||||||
|
desc: 'Pitch readings kept for key detection. ~2000 ≈ 1 min, 12000 ≈ whole session. Larger = more stable key.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'keyVoteWindow',
|
||||||
|
label: 'Key Vote Window',
|
||||||
|
min: 4, max: 30, step: 1,
|
||||||
|
desc: 'Rolling window of key votes. Larger = more inertia — key changes need sustained evidence.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'keyVoteThreshold',
|
||||||
|
label: 'Key Vote Threshold',
|
||||||
|
min: 1, max: 30, step: 1,
|
||||||
|
desc: 'Votes needed within the window to confirm a key. Higher = stricter consensus required.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chordNoteBoost',
|
||||||
|
label: 'Chord Note Boost',
|
||||||
|
min: 0, max: 10, step: 1,
|
||||||
|
desc: 'Times confirmed chord tones are injected into note history. Higher = chords dominate over transient melody notes.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: 'Audio Input',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'minClarity',
|
||||||
|
label: 'Min Pitch Clarity',
|
||||||
|
min: 0.50, max: 0.99, step: 0.01,
|
||||||
|
desc: 'Autocorrelation clarity threshold to accept a pitch reading. Higher = only clean, in-tune notes count.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minVolume',
|
||||||
|
label: 'Min Volume (RMS)',
|
||||||
|
min: 0.001, max: 0.05, step: 0.001,
|
||||||
|
desc: 'Minimum signal level before processing. Increase to cut through room noise.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Settings({ config, onChange, onClose, onReset, monoColor, onMonoColorChange }) {
|
||||||
|
// Snapshot on mount so Cancel can restore
|
||||||
|
const savedConfig = useRef(config)
|
||||||
|
const savedMono = useRef(monoColor)
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
Object.entries(savedConfig.current).forEach(([k, v]) => onChange(k, v))
|
||||||
|
onMonoColorChange(savedMono.current)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceSelector({ config, onChange }) {
|
||||||
|
const [devices, setDevices] = useState([])
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
|
||||||
|
const list = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
setDevices(list.filter(d => d.kind === 'audioinput'))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('enumerateDevices failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { refresh() }, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<select
|
||||||
|
value={config.audioDeviceId ?? ''}
|
||||||
|
onChange={e => onChange('audioDeviceId', e.target.value === '' ? null : e.target.value)}
|
||||||
|
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors w-full"
|
||||||
|
>
|
||||||
|
<option value="">System default</option>
|
||||||
|
{devices.map((d, i) => (
|
||||||
|
<option key={d.deviceId || i} value={d.deviceId}>{d.label || `Microphone ${i + 1}`}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={refresh} className="px-3 py-1 rounded-lg border border-border text-sm text-gray-400">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">If device labels are empty, grant microphone permission first and hit Refresh.</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
|
||||||
|
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-accent">Detection Settings</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">Tune chord and key recognition sensitivity in real time</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border border-border text-gray-500 hover:text-gray-300 hover:border-gray-400 transition-all"
|
||||||
|
>
|
||||||
|
Reset defaults
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border border-border text-gray-500 hover:text-gray-300 hover:border-gray-400 transition-all"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-5 py-2 rounded-lg text-sm bg-accent hover:bg-purple-600 text-white font-semibold transition-all"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
|
||||||
|
{/* ── Display ── */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
|
||||||
|
Display
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-200">Mono Color Mode</p>
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">Use a single purple palette instead of purple + amber for note tiers.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onMonoColorChange(v => !v)}
|
||||||
|
className={`relative w-11 h-6 rounded-full transition-colors ${monoColor ? 'bg-accent' : 'bg-gray-700'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${monoColor ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{SETTINGS.map(section => (
|
||||||
|
<div key={section.section}>
|
||||||
|
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
|
||||||
|
{section.section}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{section.items.map(item => (
|
||||||
|
<div key={item.key}>
|
||||||
|
<div className="flex items-baseline justify-between mb-1.5">
|
||||||
|
<label className="text-sm font-semibold text-gray-200">{item.label}</label>
|
||||||
|
<span className="text-sm font-mono text-accent w-20 text-right">
|
||||||
|
{Number.isInteger(config[item.key])
|
||||||
|
? config[item.key]
|
||||||
|
: config[item.key].toFixed(item.step < 0.01 ? 3 : 2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={item.min}
|
||||||
|
max={item.max}
|
||||||
|
step={item.step}
|
||||||
|
value={config[item.key]}
|
||||||
|
onChange={e => {
|
||||||
|
const val = item.step < 1
|
||||||
|
? parseFloat(e.target.value)
|
||||||
|
: parseInt(e.target.value, 10)
|
||||||
|
onChange(item.key, val)
|
||||||
|
}}
|
||||||
|
className="w-full accent-purple-500"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-gray-700 mt-0.5">
|
||||||
|
<span>{item.min}</span>
|
||||||
|
<span className="text-gray-600 text-center flex-1 px-2">{item.desc}</span>
|
||||||
|
<span>{item.max}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Audio device selector */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
|
||||||
|
Microphone
|
||||||
|
</h3>
|
||||||
|
<DeviceSelector config={config} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useAudioTuner } from '../services/audioService'
|
||||||
|
|
||||||
|
// Tuner UI uses pitch data from `useAudioTuner` (autocorrelation handled in the hook)
|
||||||
|
|
||||||
|
export default function Tuner() {
|
||||||
|
const { pitchData, isListening, startListening, stopListening } = useAudioTuner()
|
||||||
|
const [octaveShift, setOctaveShift] = useState(0)
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const historyRef = useRef([])
|
||||||
|
|
||||||
|
// smoothing for display to reduce jitter
|
||||||
|
const smoothedRef = useRef({ cents: 0, freq: 0 })
|
||||||
|
const [display, setDisplay] = useState({ cents: 0, freq: 0 })
|
||||||
|
const SMOOTH_ALPHA = 0.25
|
||||||
|
|
||||||
|
// wire pitchData -> smoothing and history
|
||||||
|
useEffect(() => {
|
||||||
|
const newFreq = pitchData?.freq || 0
|
||||||
|
const newCents = pitchData?.cents || 0
|
||||||
|
const prev = smoothedRef.current
|
||||||
|
const sf = prev.freq + (newFreq - prev.freq) * SMOOTH_ALPHA
|
||||||
|
const sc = prev.cents + (newCents - prev.cents) * SMOOTH_ALPHA
|
||||||
|
smoothedRef.current = { freq: sf, cents: sc }
|
||||||
|
setDisplay({ freq: Math.round(sf), cents: Math.round(sc) })
|
||||||
|
|
||||||
|
if (isListening && pitchData) {
|
||||||
|
historyRef.current.push(pitchData.cents)
|
||||||
|
if (historyRef.current.length > 120) historyRef.current.shift()
|
||||||
|
} else if (!isListening) {
|
||||||
|
historyRef.current = []
|
||||||
|
}
|
||||||
|
}, [pitchData, isListening])
|
||||||
|
|
||||||
|
// Draw the scrolling graph on the canvas
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
let rafId
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
canvas.width = Math.floor(rect.width * dpr)
|
||||||
|
canvas.height = Math.floor(rect.height * dpr)
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeCanvas()
|
||||||
|
window.addEventListener('resize', resizeCanvas)
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const { width, height } = canvas
|
||||||
|
// clear (canvas uses device pixels but ctx scaled)
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// logical width/height in CSS pixels
|
||||||
|
const w = canvas.width / (window.devicePixelRatio || 1)
|
||||||
|
const h = canvas.height / (window.devicePixelRatio || 1)
|
||||||
|
|
||||||
|
// Draw background bands (center green, sides red)
|
||||||
|
ctx.fillStyle = 'rgba(16,185,129,0.12)'
|
||||||
|
ctx.fillRect(w * 0.4, 0, w * 0.2, h)
|
||||||
|
ctx.fillStyle = 'rgba(16,185,129,0.28)'
|
||||||
|
ctx.fillRect(w * 0.48, 0, w * 0.04, h)
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(239,68,68,0.12)'
|
||||||
|
ctx.fillRect(0, 0, w * 0.4, h)
|
||||||
|
ctx.fillRect(w * 0.6, 0, w * 0.4, h)
|
||||||
|
|
||||||
|
const history = historyRef.current
|
||||||
|
if (history.length > 1) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.strokeStyle = 'white'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
|
||||||
|
const step = h / (history.length - 1)
|
||||||
|
history.forEach((cents, i) => {
|
||||||
|
const x = (w / 2) + (cents * (w / 100))
|
||||||
|
const y = h - (i * step)
|
||||||
|
if (i === 0) ctx.moveTo(x, y)
|
||||||
|
else ctx.lineTo(x, y)
|
||||||
|
})
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
const last = history[history.length - 1]
|
||||||
|
const currentX = (w / 2) + (last * (w / 100))
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.fillStyle = 'white'
|
||||||
|
ctx.arc(currentX, h - 0, 4, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
|
||||||
|
draw()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', resizeCanvas)
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
}
|
||||||
|
}, [isListening])
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<div className="flex items-start justify-end mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => (isListening ? stopListening() : startListening())}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-semibold ${isListening ? 'bg-red-600' : 'bg-accent'}`}
|
||||||
|
>
|
||||||
|
{isListening ? 'Stop' : 'Start'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col items-center">
|
||||||
|
<div className="w-full max-w-3xl">
|
||||||
|
<div className="flex items-center justify-between mb-3 px-6">
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-2xl font-semibold">{display.freq || '—'}</div>
|
||||||
|
<div className="text-xs text-gray-400">HERTZ</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-6xl font-bold tracking-tight">{pitchData ? `${pitchData.note}${pitchData.octave}` : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-semibold">{display.cents >= 0 ? `+${display.cents}` : display.cents}</div>
|
||||||
|
<div className="text-xs text-gray-400">CENTS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative bg-black/40 rounded-md overflow-hidden h-90 mb-3">
|
||||||
|
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||||
|
|
||||||
|
<div className="absolute inset-y-0 left-1/2 transform -translate-x-1/2 w-1/6 pointer-events-none">
|
||||||
|
<div className="absolute inset-0 bg-green-600/50 mx-auto w-full rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-y-0 left-0 w-5/12 bg-red-600/20 pointer-events-none" />
|
||||||
|
<div className="absolute inset-y-0 right-0 w-5/12 bg-red-600/20 pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-0.5 h-24 bg-white/30 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@config "../tailwind.config.js";
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #0f0f0f;
|
||||||
|
color: #f5f5f5;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
/* ponytail: mobile PWA full-bleed bg with content cleared from notch / home indicator. viewport-fit=cover lets bg extend under insets; app-shell pads content back in. */
|
||||||
|
overscroll-behavior: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
padding:
|
||||||
|
max(0.75rem, env(safe-area-inset-top))
|
||||||
|
max(0.75rem, env(safe-area-inset-right))
|
||||||
|
max(0.75rem, env(safe-area-inset-bottom))
|
||||||
|
max(0.75rem, env(safe-area-inset-left));
|
||||||
|
}
|
||||||
@@ -57,31 +57,54 @@ export const CHORD_TYPES = {
|
|||||||
|
|
||||||
// Chord types considered during real-time chroma matching
|
// Chord types considered during real-time chroma matching
|
||||||
const MATCH_CHORD_TYPES = [
|
const MATCH_CHORD_TYPES = [
|
||||||
'maj', 'min', 'dom7', 'min7', 'dim', 'half_dim', 'aug', 'sus4', 'add9',
|
'maj', 'min', 'dom7', 'maj7', 'min7', 'dim', 'half_dim', 'aug', 'sus4', 'sus2', 'add9',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Minimum score for a chord match to be reported
|
// Minimum score for a chord match to be reported
|
||||||
const CHORD_MATCH_MIN_SCORE = 0.42
|
const CHORD_MATCH_MIN_SCORE = 0.42
|
||||||
// Minimum margin over second-best for a match to be considered unambiguous
|
// Minimum margin over second-best for a match to be considered unambiguous
|
||||||
const CHORD_MATCH_MIN_MARGIN = 0.04
|
const CHORD_MATCH_MIN_MARGIN = 0.07
|
||||||
|
|
||||||
// Chord quality for each scale degree in major and minor
|
// Chord quality for each scale degree, per mode
|
||||||
const DEGREE_QUALITIES = {
|
const DEGREE_QUALITIES = {
|
||||||
major: ['', 'm', 'm', '', '', 'm', 'dim'],
|
major: ['', 'm', 'm', '', '', 'm', 'dim'],
|
||||||
minor: ['m', 'dim', '', 'm', 'm', '', '' ],
|
minor: ['m', 'dim', '', 'm', 'm', '', '' ],
|
||||||
|
dorian: ['m', 'm', '', '', 'm', 'dim', '' ],
|
||||||
|
phrygian: ['m', '', '', 'm', 'dim','', 'm' ],
|
||||||
|
lydian: ['', '', 'm', 'dim', '', 'm', 'm' ],
|
||||||
|
mixolydian: ['', 'm', 'dim', '', 'm', 'm', '' ],
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROMAN_NUMERALS = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']
|
const ROMAN_NUMERALS = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']
|
||||||
|
|
||||||
// Common chord progressions by genre, expressed as semitone offsets from the root
|
// Common chord progressions by genre, expressed as semitone offsets from the root
|
||||||
const PROGRESSIONS = {
|
const PROGRESSIONS = {
|
||||||
|
// Pop
|
||||||
pop: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] },
|
pop: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] },
|
||||||
|
pop2: { name: 'Pop', rn: ['I', 'IV', 'vi', 'V'], degrees: [0, 5, 9, 7] },
|
||||||
|
pop3: { name: 'Pop', rn: ['I', 'vi', 'ii', 'V'], degrees: [0, 9, 2, 7] },
|
||||||
|
// Blues
|
||||||
blues: { name: 'Blues', rn: ['I', 'IV', 'V'], degrees: [0, 5, 7] },
|
blues: { name: 'Blues', rn: ['I', 'IV', 'V'], degrees: [0, 5, 7] },
|
||||||
|
blues2: { name: 'Blues', rn: ['I', 'IV', 'V', 'IV'], degrees: [0, 5, 7, 5] },
|
||||||
|
blues3: { name: 'Blues', rn: ['I', 'I', 'IV', 'V'], degrees: [0, 0, 5, 7] },
|
||||||
|
// Folk
|
||||||
folk: { name: 'Folk', rn: ['I', 'IV', 'I', 'V'], degrees: [0, 5, 0, 7] },
|
folk: { name: 'Folk', rn: ['I', 'IV', 'I', 'V'], degrees: [0, 5, 0, 7] },
|
||||||
|
folk2: { name: 'Folk', rn: ['I', 'V', 'IV', 'I'], degrees: [0, 7, 5, 0] },
|
||||||
|
folk3: { name: 'Folk', rn: ['I', 'ii', 'IV', 'V'], degrees: [0, 2, 5, 7] },
|
||||||
|
// Jazz
|
||||||
jazz: { name: 'Jazz', rn: ['ii', 'V', 'I'], degrees: [2, 7, 0] },
|
jazz: { name: 'Jazz', rn: ['ii', 'V', 'I'], degrees: [2, 7, 0] },
|
||||||
|
jazz2: { name: 'Jazz', rn: ['I', 'vi', 'ii', 'V'], degrees: [0, 9, 2, 7] },
|
||||||
|
jazz3: { name: 'Jazz', rn: ['iii', 'vi', 'ii', 'V'], degrees: [4, 9, 2, 7] },
|
||||||
|
// Rock
|
||||||
rock: { name: 'Rock', rn: ['I', 'bVII', 'IV', 'I'], degrees: [0, 10, 5, 0] },
|
rock: { name: 'Rock', rn: ['I', 'bVII', 'IV', 'I'], degrees: [0, 10, 5, 0] },
|
||||||
|
rock2: { name: 'Rock', rn: ['I', 'IV', 'V', 'I'], degrees: [0, 5, 7, 0] },
|
||||||
|
rock3: { name: 'Rock', rn: ['I', 'bVII', 'bVI', 'bVII'], degrees: [0, 10, 8, 10] },
|
||||||
|
// '50s
|
||||||
'50s': { name: "'50s", rn: ['I', 'vi', 'IV', 'V'], degrees: [0, 9, 5, 7] },
|
'50s': { name: "'50s", rn: ['I', 'vi', 'IV', 'V'], degrees: [0, 9, 5, 7] },
|
||||||
|
'50s2': { name: "'50s", rn: ['I', 'V', 'vi', 'iii'], degrees: [0, 7, 9, 4] },
|
||||||
|
// Flamenco
|
||||||
flamen: { name: 'Flamenco', rn: ['i', 'bVII', 'bVI', 'V'], degrees: [0, 10, 8, 7] },
|
flamen: { name: 'Flamenco', rn: ['i', 'bVII', 'bVI', 'V'], degrees: [0, 10, 8, 7] },
|
||||||
|
flamen2: { name: 'Flamenco', rn: ['i', 'bVI', 'bVII', 'i'], degrees: [0, 8, 10, 0] },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Internal helpers ────────────────────────────────────────────────────────
|
// ─── Internal helpers ────────────────────────────────────────────────────────
|
||||||
@@ -115,6 +138,31 @@ function noteIndex(note) {
|
|||||||
|
|
||||||
// ─── Key Detection ───────────────────────────────────────────────────────────
|
// ─── Key Detection ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* detectTopKeys(noteHistory, n) → top N key candidates sorted by confidence.
|
||||||
|
* Each entry: { root, mode, confidence }
|
||||||
|
*/
|
||||||
|
export function detectTopKeys(noteHistory, n = 3) {
|
||||||
|
if (!noteHistory || noteHistory.length < 8) return []
|
||||||
|
|
||||||
|
const freq = new Array(12).fill(0)
|
||||||
|
for (const note of noteHistory) freq[((note % 12) + 12) % 12]++
|
||||||
|
|
||||||
|
const candidates = []
|
||||||
|
for (let root = 0; root < 12; root++) {
|
||||||
|
const rotated = Array.from({ length: 12 }, (_, i) => freq[(i + root) % 12])
|
||||||
|
const scoreMaj = pearsonCorrelation(rotated, KS_MAJOR)
|
||||||
|
const scoreMin = pearsonCorrelation(rotated, KS_MINOR)
|
||||||
|
candidates.push({ root: noteName(root), mode: 'major', score: scoreMaj,
|
||||||
|
confidence: Math.max(0, Math.min(1, (scoreMaj + 1) / 2)) })
|
||||||
|
candidates.push({ root: noteName(root), mode: 'minor', score: scoreMin,
|
||||||
|
confidence: Math.max(0, Math.min(1, (scoreMin + 1) / 2)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.sort((a, b) => b.score - a.score).slice(0, n)
|
||||||
|
.map(({ root, mode, confidence }) => ({ root, mode, confidence }))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* detectKey(noteHistory) → { root, mode, confidence }
|
* detectKey(noteHistory) → { root, mode, confidence }
|
||||||
* Uses Krumhansl-Schmuckler: correlates pitch-class histogram with key profiles.
|
* Uses Krumhansl-Schmuckler: correlates pitch-class histogram with key profiles.
|
||||||
@@ -238,10 +286,13 @@ export function matchChordFromChroma(
|
|||||||
|
|
||||||
const tones = new Set(type.intervals.map(i => (r + i) % 12))
|
const tones = new Set(type.intervals.map(i => (r + i) % 12))
|
||||||
|
|
||||||
|
// Skip if root has no meaningful energy — chord without its root is unreliable
|
||||||
|
if (chroma[r] < 0.08) continue
|
||||||
|
|
||||||
let inEnergy = 0, outEnergy = 0
|
let inEnergy = 0, outEnergy = 0
|
||||||
for (let pc = 0; pc < 12; pc++) {
|
for (let pc = 0; pc < 12; pc++) {
|
||||||
if (pc === r) {
|
if (pc === r) {
|
||||||
inEnergy += chroma[pc] * 2 // root carries strongest identity signal
|
inEnergy += chroma[pc] * 1.5 // root weight reduced: 2→1.5 (less root bias)
|
||||||
} else if (tones.has(pc)) {
|
} else if (tones.has(pc)) {
|
||||||
inEnergy += chroma[pc]
|
inEnergy += chroma[pc]
|
||||||
} else {
|
} else {
|
||||||
@@ -251,7 +302,8 @@ export function matchChordFromChroma(
|
|||||||
|
|
||||||
if (inEnergy + outEnergy < 0.05) continue
|
if (inEnergy + outEnergy < 0.05) continue
|
||||||
|
|
||||||
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5)
|
// Stricter outEnergy penalty (0.7 vs 0.5) — wrong notes hurt more
|
||||||
|
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.7)
|
||||||
const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0
|
const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0
|
||||||
const diatonicBonus = diatonicSet.has(chordName) ? 0.15 : 0
|
const diatonicBonus = diatonicSet.has(chordName) ? 0.15 : 0
|
||||||
|
|
||||||
@@ -308,40 +360,131 @@ export function toRomanNumeral(chordName, keyRoot, keyMode) {
|
|||||||
|
|
||||||
// ─── Repeating progression detection ─────────────────────────────────────────
|
// ─── Repeating progression detection ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// Returns true if arr is made of a shorter repeating unit (e.g. [A,B,A,B] → true)
|
||||||
|
function isPeriodicPattern(arr) {
|
||||||
|
for (let p = 1; p <= Math.floor(arr.length / 2); p++) {
|
||||||
|
if (arr.length % p !== 0) continue
|
||||||
|
const unit = arr.slice(0, p)
|
||||||
|
if (arr.every((v, i) => v === unit[i % p])) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the lexicographically smallest rotation so the same loop always
|
||||||
|
// produces the same string regardless of where in the cycle we currently are.
|
||||||
|
function canonicalize(pattern) {
|
||||||
|
let best = pattern
|
||||||
|
for (let i = 1; i < pattern.length; i++) {
|
||||||
|
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
|
||||||
|
if (rot.join('\0') < best.join('\0')) best = rot
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* detectRepeatingProgression(history) → chord[] or null
|
* detectRepeatingProgression(history) → chord[] or null
|
||||||
* Returns the most-recently-completed repeating pattern (length 2–6).
|
*
|
||||||
* Uses non-overlapping match counting to avoid over-counting.
|
* Tests every unique subsequence of every length (not just the tail) so the
|
||||||
|
* result is stable regardless of where in the loop the musician currently is.
|
||||||
|
* Returns the canonical (rotation-normalised) form of the best pattern found.
|
||||||
*/
|
*/
|
||||||
export function detectRepeatingProgression(history) {
|
export function detectRepeatingProgression(history) {
|
||||||
if (!history || history.length < 4) return null
|
if (!history || history.length < 6) return null
|
||||||
|
|
||||||
const window = history.slice(-20)
|
const win = history.slice(-32)
|
||||||
let best = null, bestScore = 0
|
let best = null, bestScore = 0
|
||||||
|
|
||||||
for (let len = 2; len <= 6; len++) {
|
for (let len = 2; len <= 6; len++) {
|
||||||
if (len * 2 > window.length) break
|
if (len * 2 > win.length) break
|
||||||
|
|
||||||
|
const seen = new Set()
|
||||||
|
|
||||||
|
for (let start = 0; start <= win.length - len; start++) {
|
||||||
|
const candidate = win.slice(start, start + len)
|
||||||
|
const key = candidate.join('\0')
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
// A pattern that is itself a repetition of something shorter will be
|
||||||
|
// found at that shorter length — skip it here to avoid inflating scores.
|
||||||
|
if (len >= 4 && isPeriodicPattern(candidate)) continue
|
||||||
|
|
||||||
const candidate = window.slice(-len)
|
|
||||||
let reps = 0, i = 0
|
let reps = 0, i = 0
|
||||||
|
while (i <= win.length - len) {
|
||||||
while (i <= window.length - len) {
|
if (candidate.every((c, j) => c === win[i + j])) { reps++; i += len }
|
||||||
if (candidate.every((c, j) => c === window[i + j])) {
|
else i++
|
||||||
reps++
|
|
||||||
i += len // skip past match — non-overlapping
|
|
||||||
} else {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const score = reps * len
|
if (reps < 2) continue
|
||||||
if (reps >= 2 && score > bestScore) {
|
|
||||||
|
const score = reps * len * len // square length — prevents sub-patterns from beating full loop
|
||||||
|
if (score > bestScore) {
|
||||||
bestScore = score
|
bestScore = score
|
||||||
best = candidate
|
best = candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return best
|
return best ? canonicalize(best) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Debug / analysis helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns top N chord candidates with full score breakdown for the given chroma.
|
||||||
|
*/
|
||||||
|
export function getChordCandidates(chroma, keyInfo, bassPC = null, topN = 8) {
|
||||||
|
if (!keyInfo?.root || !chroma) return []
|
||||||
|
const diatonicSet = new Set(getChordsInKey(keyInfo.root, keyInfo.mode))
|
||||||
|
const candidates = []
|
||||||
|
|
||||||
|
for (let r = 0; r < 12; r++) {
|
||||||
|
for (const typeKey of MATCH_CHORD_TYPES) {
|
||||||
|
const type = CHORD_TYPES[typeKey]
|
||||||
|
const chordName = noteName(r) + type.suffix
|
||||||
|
const tones = new Set(type.intervals.map(i => (r + i) % 12))
|
||||||
|
|
||||||
|
let inEnergy = 0, outEnergy = 0
|
||||||
|
for (let pc = 0; pc < 12; pc++) {
|
||||||
|
if (pc === r) inEnergy += chroma[pc] * 2
|
||||||
|
else if (tones.has(pc)) inEnergy += chroma[pc]
|
||||||
|
else outEnergy += chroma[pc]
|
||||||
|
}
|
||||||
|
if (inEnergy + outEnergy < 0.05) continue
|
||||||
|
|
||||||
|
const coverage = inEnergy / (inEnergy + outEnergy * 0.5)
|
||||||
|
const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0
|
||||||
|
const diatBonus = diatonicSet.has(chordName) ? 0.15 : 0
|
||||||
|
const score = coverage + bassBonus + diatBonus
|
||||||
|
candidates.push({ name: chordName, score, coverage, bassBonus, diatBonus, diatonic: diatonicSet.has(chordName) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates.sort((a, b) => b.score - a.score).slice(0, topN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyses note history: returns normalised pitch-class frequencies and
|
||||||
|
* top K-S key candidates with correlation scores.
|
||||||
|
*/
|
||||||
|
export function getNoteHistoryAnalysis(noteHistory) {
|
||||||
|
const freq = new Array(12).fill(0)
|
||||||
|
if (!noteHistory?.length) return { freq, topKeys: [] }
|
||||||
|
|
||||||
|
for (const note of noteHistory) freq[((note % 12) + 12) % 12]++
|
||||||
|
const total = noteHistory.length
|
||||||
|
const normalized = freq.map(f => f / total)
|
||||||
|
|
||||||
|
const candidates = []
|
||||||
|
for (let root = 0; root < 12; root++) {
|
||||||
|
const rotated = Array.from({ length: 12 }, (_, i) => normalized[(i + root) % 12])
|
||||||
|
candidates.push({ root: noteName(root), mode: 'major', score: pearsonCorrelation(rotated, KS_MAJOR) })
|
||||||
|
candidates.push({ root: noteName(root), mode: 'minor', score: pearsonCorrelation(rotated, KS_MINOR) })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
freq: normalized,
|
||||||
|
total,
|
||||||
|
topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Utilities ───────────────────────────────────────────────────────────────
|
// ─── Utilities ───────────────────────────────────────────────────────────────
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export const NOTES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
|
||||||
|
|
||||||
|
// simple autocorrelation from existing tuner
|
||||||
|
function autoCorrelate(buffer, sampleRate) {
|
||||||
|
let SIZE = buffer.length
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 0; i < SIZE; i++) {
|
||||||
|
const val = buffer[i]
|
||||||
|
sum += val * val
|
||||||
|
}
|
||||||
|
if (sum < 1e-8) return -1
|
||||||
|
|
||||||
|
let r1 = 0, r2 = SIZE - 1, thres = 0.2
|
||||||
|
for (let i = 0; i < SIZE / 2; i++) {
|
||||||
|
if (Math.abs(buffer[i]) < thres) { r1 = i; break }
|
||||||
|
}
|
||||||
|
for (let i = 1; i < SIZE / 2; i++) {
|
||||||
|
if (Math.abs(buffer[SIZE - i]) < thres) { r2 = SIZE - i; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = buffer.slice(r1, r2)
|
||||||
|
SIZE = buffer.length
|
||||||
|
|
||||||
|
const c = new Array(SIZE).fill(0)
|
||||||
|
for (let i = 0; i < SIZE; i++) for (let j = 0; j < SIZE - i; j++) c[i] += buffer[j] * buffer[j + i]
|
||||||
|
|
||||||
|
let d = 0
|
||||||
|
while (c[d] > c[d + 1]) d++
|
||||||
|
|
||||||
|
let maxval = -1, maxpos = -1
|
||||||
|
for (let i = d; i < SIZE; i++) if (c[i] > maxval) { maxval = c[i]; maxpos = i }
|
||||||
|
let T0 = maxpos
|
||||||
|
if (!T0) return -1
|
||||||
|
|
||||||
|
const x1 = c[T0 - 1]
|
||||||
|
const x2 = c[T0]
|
||||||
|
const x3 = c[T0 + 1]
|
||||||
|
const a = (x1 + x3 - 2 * x2) / 2
|
||||||
|
const b = (x3 - x1) / 2
|
||||||
|
if (a) T0 = T0 - b / (2 * a)
|
||||||
|
|
||||||
|
return sampleRate / T0
|
||||||
|
}
|
||||||
|
|
||||||
|
function frequencyToNoteData(freq) {
|
||||||
|
if (!freq || freq <= 0) return null
|
||||||
|
const A4 = 440
|
||||||
|
const noteNum = 12 * Math.log2(freq / A4) + 69
|
||||||
|
const rounded = Math.round(noteNum)
|
||||||
|
const cents = Math.floor((noteNum - rounded) * 100)
|
||||||
|
const noteNames = NOTES
|
||||||
|
const name = noteNames[(rounded + 120) % 12]
|
||||||
|
const octave = Math.floor(rounded / 12) - 1
|
||||||
|
return { freq, note: name, octave, cents }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAudioTuner() {
|
||||||
|
const [pitchData, setPitchData] = useState(null)
|
||||||
|
const [isListening, setIsListening] = useState(false)
|
||||||
|
|
||||||
|
const audioRef = useRef({ ctx: null, analyser: null, source: null, stream: null, raf: null, buf: null })
|
||||||
|
const prevPitchRef = useRef(null)
|
||||||
|
const lastUpdateRef = useRef(0)
|
||||||
|
const UPDATE_INTERVAL_MS = 80 // ~12.5 updates/sec
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const { raf, stream, source, ctx } = audioRef.current
|
||||||
|
if (raf) cancelAnimationFrame(raf)
|
||||||
|
if (source) try { source.disconnect() } catch (e) {}
|
||||||
|
if (stream) for (const t of stream.getTracks()) t.stop()
|
||||||
|
if (ctx) try { ctx.close() } catch (e) {}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startListening = async () => {
|
||||||
|
if (isListening) return
|
||||||
|
try {
|
||||||
|
// Read saved device selection from config (if present)
|
||||||
|
let deviceId = null
|
||||||
|
try {
|
||||||
|
const cfg = JSON.parse(localStorage.getItem('wtf_config') || '{}')
|
||||||
|
deviceId = cfg.audioDeviceId || null
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const constraints = deviceId ? { audio: { deviceId: { exact: deviceId } } } : { audio: true }
|
||||||
|
console.log('Tuner requesting getUserMedia with', constraints)
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)()
|
||||||
|
const analyser = ctx.createAnalyser()
|
||||||
|
analyser.fftSize = 4096
|
||||||
|
analyser.smoothingTimeConstant = 0.6
|
||||||
|
const source = ctx.createMediaStreamSource(stream)
|
||||||
|
source.connect(analyser)
|
||||||
|
|
||||||
|
const buf = new Float32Array(analyser.fftSize)
|
||||||
|
audioRef.current = { ctx, analyser, source, stream, raf: null, buf }
|
||||||
|
|
||||||
|
setIsListening(true)
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const { analyser, buf, ctx } = audioRef.current
|
||||||
|
analyser.getFloatTimeDomainData(buf)
|
||||||
|
const freq = autoCorrelate(buf, ctx.sampleRate)
|
||||||
|
const data = frequencyToNoteData(freq)
|
||||||
|
|
||||||
|
// compute RMS signal strength; simple noise gate
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]
|
||||||
|
const rms = Math.sqrt(sum / buf.length)
|
||||||
|
|
||||||
|
// throttle updates to avoid excessive React state churn
|
||||||
|
const now = performance.now()
|
||||||
|
const prev = prevPitchRef.current
|
||||||
|
if (rms < 0.001) {
|
||||||
|
if (prev !== null && now - lastUpdateRef.current >= UPDATE_INTERVAL_MS) {
|
||||||
|
prevPitchRef.current = null
|
||||||
|
lastUpdateRef.current = now
|
||||||
|
setPitchData(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const shouldUpdateByTime = now - lastUpdateRef.current >= UPDATE_INTERVAL_MS
|
||||||
|
if (data && prev) {
|
||||||
|
const freqDiff = Math.abs((prev.freq || 0) - (data.freq || 0))
|
||||||
|
const centsDiff = Math.abs((prev.cents || 0) - (data.cents || 0))
|
||||||
|
const significant = freqDiff >= 0.5 || centsDiff >= 1
|
||||||
|
if (significant && shouldUpdateByTime) {
|
||||||
|
prevPitchRef.current = data
|
||||||
|
lastUpdateRef.current = now
|
||||||
|
setPitchData(data)
|
||||||
|
}
|
||||||
|
// otherwise skip update (either insignificant or too soon)
|
||||||
|
} else if (shouldUpdateByTime) {
|
||||||
|
// first time audible or no prev — update
|
||||||
|
prevPitchRef.current = data
|
||||||
|
lastUpdateRef.current = now
|
||||||
|
setPitchData(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRef.current.raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRef.current.raf = requestAnimationFrame(tick)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('startListening error', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopListening = () => {
|
||||||
|
const { raf, stream, source, ctx } = audioRef.current
|
||||||
|
if (raf) cancelAnimationFrame(raf)
|
||||||
|
if (source) try { source.disconnect() } catch (e) {}
|
||||||
|
if (stream) for (const t of stream.getTracks()) t.stop()
|
||||||
|
if (ctx) try { ctx.close() } catch (e) {}
|
||||||
|
audioRef.current = { ctx: null, analyser: null, source: null, stream: null, raf: null, buf: null }
|
||||||
|
setIsListening(false)
|
||||||
|
setPitchData(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pitchData, isListening, startListening, stopListening }
|
||||||
|
}
|
||||||
@@ -1,675 +0,0 @@
|
|||||||
version = 1
|
|
||||||
revision = 3
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "annotated-doc"
|
|
||||||
version = "0.0.4"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "annotated-types"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anthropic"
|
|
||||||
version = "0.84.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "anyio" },
|
|
||||||
{ name = "distro" },
|
|
||||||
{ name = "docstring-parser" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "jiter" },
|
|
||||||
{ name = "pydantic" },
|
|
||||||
{ name = "sniffio" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anyio"
|
|
||||||
version = "4.12.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "idna" },
|
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "certifi"
|
|
||||||
version = "2026.2.25"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "click"
|
|
||||||
version = "8.3.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorama"
|
|
||||||
version = "0.4.6"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "distro"
|
|
||||||
version = "1.9.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "docstring-parser"
|
|
||||||
version = "0.17.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastapi"
|
|
||||||
version = "0.135.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "annotated-doc" },
|
|
||||||
{ name = "pydantic" },
|
|
||||||
{ name = "starlette" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
{ name = "typing-inspection" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "h11"
|
|
||||||
version = "0.16.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpcore"
|
|
||||||
version = "1.0.9"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "h11" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httptools"
|
|
||||||
version = "0.7.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpx"
|
|
||||||
version = "0.28.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "anyio" },
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "httpcore" },
|
|
||||||
{ name = "idna" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna"
|
|
||||||
version = "3.11"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiter"
|
|
||||||
version = "0.13.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ollama"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "pydantic" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pydantic"
|
|
||||||
version = "2.12.5"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "annotated-types" },
|
|
||||||
{ name = "pydantic-core" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
{ name = "typing-inspection" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pydantic-core"
|
|
||||||
version = "2.41.5"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pydantic-settings"
|
|
||||||
version = "2.13.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "pydantic" },
|
|
||||||
{ name = "python-dotenv" },
|
|
||||||
{ name = "typing-inspection" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "python-dotenv"
|
|
||||||
version = "1.2.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyyaml"
|
|
||||||
version = "6.0.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sniffio"
|
|
||||||
version = "1.3.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "starlette"
|
|
||||||
version = "0.52.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "anyio" },
|
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-extensions"
|
|
||||||
version = "4.15.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-inspection"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "uvicorn"
|
|
||||||
version = "0.41.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "click" },
|
|
||||||
{ name = "h11" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
standard = [
|
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
||||||
{ name = "httptools" },
|
|
||||||
{ name = "python-dotenv" },
|
|
||||||
{ name = "pyyaml" },
|
|
||||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
|
||||||
{ name = "watchfiles" },
|
|
||||||
{ name = "websockets" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "uvloop"
|
|
||||||
version = "0.22.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "watchfiles"
|
|
||||||
version = "1.1.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "anyio" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "websockets"
|
|
||||||
version = "16.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "whattheflat"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = { virtual = "." }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "anthropic" },
|
|
||||||
{ name = "fastapi" },
|
|
||||||
{ name = "ollama" },
|
|
||||||
{ name = "pydantic" },
|
|
||||||
{ name = "pydantic-settings" },
|
|
||||||
{ name = "python-dotenv" },
|
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
|
||||||
dev = [
|
|
||||||
{ name = "httpx" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [
|
|
||||||
{ name = "anthropic", specifier = ">=0.40.0" },
|
|
||||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
|
||||||
{ name = "ollama", specifier = ">=0.4.0" },
|
|
||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
|
||||||
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
|
||||||
dev = [{ name = "httpx", specifier = ">=0.27.0" }]
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['apple-touch-icon.png', 'icon-192.png', 'icon-512.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'JamBuddy — WhatTheFlat',
|
||||||
|
short_name: 'JamBuddy',
|
||||||
|
description: 'Real-time key and chord detection for live jams.',
|
||||||
|
theme_color: '#0f0f0f',
|
||||||
|
background_color: '#0f0f0f',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
|
start_url: './',
|
||||||
|
scope: './',
|
||||||
|
icons: [
|
||||||
|
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: 'icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2,webmanifest}'],
|
||||||
|
navigateFallback: 'index.html',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
base: './', // relative paths so Electron can load files from disk
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
})
|
||||||