From c0ef67633ea75c0dd7df67eba482f25681d6dcf7 Mon Sep 17 00:00:00 2001 From: Shaurya Saria Date: Sun, 13 Sep 2026 21:47:44 +0530 Subject: [PATCH 1/5] feat: polish showcase browser experience --- README.md | 7 +- TODO.md | 11 +- docs/01-product-requirements.md | 14 + docs/02-technical-requirements.md | 10 +- docs/05-backend-schema.md | 6 +- showcase/audio-recognition/case-study.md | 15 +- .../audio-recognition/evidence-checklist.md | 17 +- .../audio-recognition/presentation-outline.md | 10 +- .../audio-recognition/presentation-script.md | 6 +- tests/test_web.py | 6 + web/static/app.js | 401 +++++--- web/static/style.css | 870 +++++++++++++++++- web/templates/index.html | 146 +-- 13 files changed, 1299 insertions(+), 220 deletions(-) diff --git a/README.md b/README.md index 6b43be5..4dbc4b6 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,15 @@ Validated audio pipeline · Multi-backend matching · Flask web UI · Terminal o ## Overview DIY Shazam captures audio from the CLI microphone/file path or the Flask browser UI, normalizes it through one bounded audio pipeline, and identifies tracks using RapidAPI/Shazam, AcoustID, AudD, or local spectrogram peaks and constellation hash pairs. FFT output is a diagnostic visualization only; it is not the recognition algorithm. Flask serves the complete browser UI and JSON API from one origin. +The supported web experience is a focused, local-first showcase MVP: a microphone-first recognition flow, a supported upload path, readable runtime diagnostics, recovery-oriented result states, and optional session-only history. Authentication, persistent history, a public deployment, and real-world accuracy claims remain explicit follow-on work rather than implied features. + ## Implemented - Flask is the supported browser application; `web/app.py` serves the UI and JSON API from one origin. - CLI and web inputs use the documented bounded normalization pipeline. The CLI accepts WAV/PCM; the web path converts the documented browser upload formats through FFmpeg. - Provider dispatch uses RapidAPI/Shazam, AcoustID, AudD, and the local constellation-hash backend with stable public statuses and safe diagnostics. - Production configuration includes Gunicorn, `/healthz`, `/readyz`, bounded uploads and FFmpeg work, atomic Supabase quota operations, trusted-proxy controls, and debug-off defaults outside explicit development mode. +- The browser surface uses an accessible microphone-first CTA, expandable runtime details, structured match/error cards, safe external links, theme persistence, and focus-aware song details. - The current checkout has 193 passing Python tests. CI also defines Ruff, coverage, dependency-audit, and secret-scanning gates. ## Known limitations @@ -78,7 +81,7 @@ flowchart LR classDef blue fill:#ffffff,stroke:#1E90FF,stroke-width:2px,color:#1E90FF; class A,B,C,D,E,F,G,H,I,J,K,L blue; ``` -Theme: black / white / blue — white nodes with a professional DodgerBlue accent (#1E90FF). The browser UI is served directly by Flask; there is no separate browser bundle. +Theme: midnight navy / soft white / electric blue. The browser UI is served directly by Flask; there is no separate browser bundle. ## Quickstart ### Windows PowerShell @@ -181,7 +184,7 @@ Entrypoints remain: Supported env vars (see `shazam_project.config.load_config()`): `AUDD_API_TOKEN`, `ACOUSTID_API_KEY`, `FP_CALC_PATH`, `RAPIDAPI_KEY`, and optional `LOCAL_FINGERPRINT_INDEX` (with `FINGERPRINT_INDEX_PATH` accepted as a legacy alias). The shared audio contract is controlled by `INTERNAL_SAMPLE_RATE`, `MIN_AUDIO_SECONDS`, `MAX_AUDIO_SECONDS`, `MAX_UPLOAD_BYTES`, and `FFMPEG_TIMEOUT_SECONDS`; provider WAVs are always fixed 16-bit PCM. Matcher order is RapidAPI → AcoustID → AudD → local fingerprint index. ## Web UI -`python web/app.py` serves `/`, `/static/*`, `/api/match`, and `/api/status` from the same origin. CLI file mode accepts WAV/PCM files. Web uploads support WAV, MP3, M4A, AAC, OGG, FLAC, and WEBM; non-WAV web uploads require FFmpeg on `PATH` and are converted before decoding. The browser also supports microphone recording, manual stop, waveform visualization, loading/error/no-match states, light/dark theme persistence, and session-only recognition history. +`python web/app.py` serves `/`, `/static/*`, `/api/match`, and `/api/status` from the same origin. CLI file mode accepts WAV/PCM files. Web uploads support WAV, MP3, M4A, AAC, OGG, FLAC, and WEBM; non-WAV web uploads require FFmpeg on `PATH` and are converted before decoding. The browser also supports a microphone-first recording flow, manual stop, waveform visualization, expandable capability details, structured loading/error/no-match/match states, light/dark theme persistence, focus-aware song details, and session-only recognition history. The durations are intentionally different by path: CLI microphone mode defaults to 8 seconds and accepts an interactive override; the RapidAPI/Shazam adapter sends at most the first 5 seconds of the normalized clip; browser recording auto-stops after 10 seconds but can be stopped manually; and the reproducible benchmark uses separate 4-second, 8-second, and 15-second microphone clips. diff --git a/TODO.md b/TODO.md index 8e5a158..70de756 100644 --- a/TODO.md +++ b/TODO.md @@ -181,7 +181,7 @@ missing lawful corpus, credentials, or real benchmark execution. - [x] Add RapidAPI configuration to `/api/status`; report the actual active backend order. - [x] Standardize response fields and statuses across all providers and the CLI/browser consumers, including local `no_match` responses. - [x] Correct the CLI missing-configuration message so it identifies missing recognition configuration generically rather than naming AudD only. -- [ ] Add a health/startup check that clearly reports missing provider, Supabase, FFmpeg, and `fpcalc` configuration. +- [x] Add a health/startup check that clearly reports missing provider, Supabase, FFmpeg, and `fpcalc` configuration. ### Rate limiting and production safety @@ -197,7 +197,7 @@ missing lawful corpus, credentials, or real benchmark execution. - [x] Add `Retry-After` headers to rate-limit responses. - [x] Disable `debug=True` outside an explicitly local development mode. - [ ] Add a production WSGI/server configuration and document deployment assumptions. -- [ ] Add tests for unauthorized requests, daily limits, monthly limits, cooldowns, concurrent requests, and Supabase failures. +- [x] Add tests for unauthorized requests, daily limits, monthly limits, cooldowns, concurrent requests, and Supabase failures. ## P1 — engineering quality and verification @@ -231,10 +231,10 @@ missing lawful corpus, credentials, or real benchmark execution. ## P1 — documentation reconciliation -- [ ] Split documentation into “Implemented”, “Known limitations”, “Planned”, and “Evaluation evidence”. -- [ ] Mark Supabase authentication, persistent user history, RLS-backed history, account deletion, settings, and protected routes as planned unless implemented. +- [x] Split documentation into “Implemented”, “Known limitations”, “Planned”, and “Evaluation evidence”. +- [x] Mark Supabase authentication, persistent user history, RLS-backed history, account deletion, settings, and protected routes as planned unless implemented. - [x] Reconcile the README with the actual Flask source tree. -- [ ] Reconcile the documented 8-second CLI behavior, 5-second RapidAPI trim, and 10-second browser recording behavior. +- [x] Reconcile the documented 8-second CLI behavior, 5-second RapidAPI trim, and 10-second browser recording behavior. - [x] Document that the current FFT is diagnostic and not used for matching. - [x] Replace the stale README “Add CI” roadmap entry with the implemented pytest, coverage, lint, and build gates. - [ ] Document the exact source and command used to generate each screenshot. @@ -273,6 +273,7 @@ Record evidence here as work lands: | Date | Task/check | Evidence | Result | |---|---|---|---| +| 2026-09-13 | Showcase-ready browser polish | Feature branch `codex/showcase-ready-20260913`; local Flask browser smoke verified first viewport, theme toggle, runtime-details disclosure, empty-upload recovery, no horizontal overflow at 390px/320px, and no current console errors. | Microphone-first CTA, structured result/error cards, focus-aware details modal, safe external links, and current/roadmap documentation boundary added. Provider credentials, real recording, benchmark corpus, and live deployment remain open. | | 2026-08-17 | Current checkout audit and documentation reconciliation | Dedicated branch `codex/audio-recognition-p0-audit`; supported `.venv-pipeline` ran 193 tests; FFmpeg and fpcalc were available; local browser smoke checked page load, status rendering, and unsupported-upload handling; `/readyz`/quota/WSGI behavior is covered by repository tests. | Production/configuration and documentation checkboxes updated. No provider values, source catalog, microphone clips, benchmark results, or credentialed smoke evidence are present locally, so the real benchmark and release gates remain open. | | 2026-08-01 | Production rate limits | Added `production_rate_limits` migration through the Supabase CLI; private row-locked quota RPC, RLS with no public policies, server-only service-role access, HMAC client identifiers, fail-closed 503 handling, development fallback, trusted-proxy configuration, direct API-secret authentication, and Retry-After responses. | 93 tests passed; 68% total branch coverage; compileall and diff checks passed. Local/linked SQL execution remains unavailable: Docker is not running and the linked `shazam-project` is inactive; linked advisors returned no lints and migration listing timed out. | | 2026-08-01 | Prompt 3 cleanup and review fixes | Flask status display restored RapidAPI and Supabase fields; local `no_match` uses the shared `result: null` shape; all providers receive normalized mono float32 audio; provider diagnostics are safe; rate limits run before upload processing; fixed 16-bit provider WAV encoding is documented; README/TODO record the validated contract and Prompt 4 production blockers. | 73 tests passed; 66% total branch coverage; compileall and diff checks passed locally; CI and `coverage.xml` artifact are pending this push; Codecov upload previously reported `Repository not found` and remains deferred | diff --git a/docs/01-product-requirements.md b/docs/01-product-requirements.md index 9efd252..9a7a027 100644 --- a/docs/01-product-requirements.md +++ b/docs/01-product-requirements.md @@ -1,5 +1,7 @@ # Product Requirements Document (PRD) +> Status: This document records the intended product direction. The current showcase slice is a guest, local-first Flask MVP. Authentication, persistent history, account deletion, and hosted user settings are not implemented and must not be inferred from this document. + **Project:** Shazam Clone — Music Recognition Web App **Author:** Shaurya Saria **Version:** 1.0 @@ -12,6 +14,18 @@ A full-stack web application that replicates core Shazam functionality — identifying songs from audio input via microphone or file upload, displaying song metadata (title, artist, album, artwork), and maintaining a personal history of identified tracks. The app targets music listeners who want quick, browser-native song identification without needing a native app. +## Current implementation boundary + +| Capability | Current state | +|---|---| +| Song recognition | Implemented through the supported Flask UI, CLI, configured provider backends, and optional local fingerprint index. | +| Guest history | Implemented as optional session-only browser history with removal and detail actions. | +| Song detail view | Implemented for returned matches, including available metadata and a safe Spotify search fallback. | +| Authentication and persistent history | Planned; no protected user routes or Supabase Auth flow is shipped. | +| Account settings and deletion | Planned; the current privacy control only governs session-history storage. | + +The showcase and README should describe the current implementation boundary above. The remaining requirements below are retained as the product roadmap, not as claims about the current runtime. + --- ## Problem Statement diff --git a/docs/02-technical-requirements.md b/docs/02-technical-requirements.md index 353fe40..d248c51 100644 --- a/docs/02-technical-requirements.md +++ b/docs/02-technical-requirements.md @@ -1,7 +1,7 @@ # Technical Requirements Document **Project:** Audio Recognition -**Status:** Current Flask implementation +**Status:** Current Flask implementation; authentication and persistent-history features remain roadmap items. ## Architecture @@ -21,7 +21,7 @@ CLI at main.py -> shared shazam_project modules -> matcher backends | Layer | Implementation | |---|---| | Browser UI | Flask templates, vanilla JavaScript, and CSS | -| Web server | Flask development server for local use; WSGI deployment is planned | +| Web server | Flask development server for local use; Gunicorn WSGI configuration for deployment | | Audio capture | Browser MediaRecorder and Web Audio API visualization | | Audio decoding | WAV loader; optional FFmpeg conversion for browser uploads | | Recognition | RapidAPI/Shazam, AcoustID, AudD, then local fingerprint index | @@ -29,7 +29,7 @@ CLI at main.py -> shared shazam_project modules -> matcher backends ## Browser behavior -The page at `/` supports file upload, microphone capture with manual stop and a ten-second limit, waveform visualization, loading and error states, matched and no-match results, light/dark theme persistence, and session-only history. History is stored in the browser session and is not an authenticated database feature. +The page at `/` supports a microphone-first recording flow, file upload, manual stop and a ten-second limit, waveform visualization, expandable runtime details, structured loading/error states, matched and no-match results, light/dark theme persistence, focus-aware song details, and session-only history. History is stored in the browser session and is not an authenticated database feature. ## API contract @@ -37,7 +37,7 @@ The page at `/` supports file upload, microphone capture with manual stop and a - `matched`: includes normalized `title`, `artist`, `album`, and optional `image` fields. - `no_match`: no configured backend identified the audio. -- `no_token`: no recognition backend is configured. +- `not_configured`: no recognition backend is configured. - `rate_limited`: the request exceeded a configured limit. - `error`: the upload, conversion, provider, or runtime path failed. @@ -45,7 +45,7 @@ The page at `/` supports file upload, microphone capture with manual stop and a ## Configuration and security -Provider keys and server configuration are loaded from the root `.env` file. The browser receives no provider keys or server secrets. Same-origin browser requests are accepted when `INTERNAL_API_SECRET` is enabled; optional external API clients must be explicitly listed in `CORS_ORIGINS`. +Provider keys and server configuration are loaded from the root `.env` file. The browser receives no provider keys or server secrets. When `INTERNAL_API_SECRET` is enabled, requests must present the secret directly; same-origin headers never bypass it. Optional external API clients must be explicitly listed in `CORS_ORIGINS`. Uploads are bounded by `MAX_UPLOAD_BYTES` and `MAX_AUDIO_SECONDS`. Temporary files are removed after processing, and provider calls have bounded timeouts. diff --git a/docs/05-backend-schema.md b/docs/05-backend-schema.md index 3edd3ec..363a102 100644 --- a/docs/05-backend-schema.md +++ b/docs/05-backend-schema.md @@ -5,11 +5,13 @@ **Date:** June 2026 **Database:** Supabase (PostgreSQL) +> Status: Design-only roadmap. The current Flask showcase does not implement Supabase Auth, `profiles`, `recognition_history`, protected user routes, or account deletion. The shipped Supabase migration is limited to server-side quota accounting; session history remains in the browser and is optional. + --- ## Overview -All user data is stored in Supabase PostgreSQL. Authentication is handled by Supabase Auth (`auth.users` table — managed automatically). The application uses two custom tables in the `public` schema: `profiles` and `recognition_history`. Row-Level Security (RLS) is enabled on all custom tables. +The intended authenticated product would store user data in Supabase PostgreSQL. Authentication would be handled by Supabase Auth (`auth.users` table — managed automatically), with `profiles` and `recognition_history` protected by Row-Level Security (RLS). These tables and flows are retained here as a future design and are not part of the current showcase runtime. --- @@ -214,7 +216,7 @@ Test the schema with an anon key and no session to confirm that: --- -## API Data Flow +## Planned authenticated API data flow ### Saving a recognized song diff --git a/showcase/audio-recognition/case-study.md b/showcase/audio-recognition/case-study.md index c64c8c0..86bb500 100644 --- a/showcase/audio-recognition/case-study.md +++ b/showcase/audio-recognition/case-study.md @@ -4,7 +4,7 @@ DIY Shazam is an audio-recognition project with a command-line entry point and a Flask browser application. It accepts microphone input and audio files, normalizes them through a shared pipeline, and attempts recognition through configured external providers or a local constellation-hash index. -**Current status:** Source-verified on audit branch `codex/audio-recognition-p0-audit` at `6eb46cf`, with 193 local pytest tests, Ruff, and `git diff --check` passing. PR #11 is pushed, open, and draft; no current-branch CI status entries were reported by the connected GitHub check. No live demo URL, complete real-world benchmark, or credentialed provider result is currently available. +**Current status:** The showcase-ready browser polish is being prepared on feature branch `codex/showcase-ready-20260913`. The feature branch passes 193 local pytest tests, 82% branch coverage, Ruff, Python compilation, and diff checks; the Flask page was also smoke-tested in the in-app browser at desktop and 390px/320px mobile widths with no current console errors. Remote `main` is at `86359d5` with successful CI run [33418693705](https://github.com/icecold009/Audio-Recognition/actions/runs/33418693705) for the merged baseline. No live demo URL, complete real-world benchmark, or credentialed provider result is currently available. ## Problem and audience @@ -64,9 +64,9 @@ The benchmark runner requires 30 source tracks, one 4-, 8-, and 15-second microp ## Implementation highlights - Flask `web/` is the single supported browser application. -- The browser supports upload, recording, waveform visualization, light/dark theme, retry states, and optional session-only history. +- The browser supports a microphone-first CTA, upload, waveform visualization, expandable runtime details, structured match/error/retry states, light/dark theme, focus-aware song details, and optional session-only history. - Web uploads support WAV, MP3, M4A, AAC, OGG, FLAC, and WEBM; FFmpeg conversion is bounded by duration and output-size limits. -- The current audit branch (`6eb46cf`) includes the `b0b8d79` audio-pipeline/browser-recovery hardening baseline plus the documentation and evaluation-gate reconciliation. +- The current feature branch adds the browser presentation slice on top of the merged audio-pipeline, production-safety, benchmark-tooling, and documentation baseline. - CI runs Python tests on 3.10, 3.11, and 3.12, Ruff formatting/lint, branch coverage, dependency auditing, secret scanning, Render Blueprint validation, and a production-container smoke path. ## Validation and results @@ -78,11 +78,12 @@ The benchmark runner requires 30 source tracks, one 4-, 8-, and 15-second microp - The repository contains a reproducible benchmark command and refuses incomplete README imports. - The checked-in FFT image is a real project artifact, but it is a diagnostic spectrum and not evidence of recognition quality. -### Verified locally on the current audit branch +### Verified locally on the current showcase feature branch -- `.venv-pipeline\Scripts\python.exe -m pytest -q` completed with `193 passed in 9.56s`; the initial non-elevated launcher failed before test collection because it could not create the configured Python process. -- `.venv-pipeline\Scripts\ruff.exe check .` passed, and `git diff --check` produced no output. +- `.venv-pipeline\Scripts\python.exe -m coverage run --branch --source=shazam_project,web,scripts -m pytest -q` completed with `193 passed in 12.23s`; the initial non-elevated launcher failed before test collection because it could not create the configured Python process. +- The coverage gate reported `82%` branch coverage, Ruff lint and formatting passed, Python compilation passed, and `git diff --check` produced no output. - A local development HTTP smoke returned 200 for `/` and `/healthz`, exposed upload and record controls, returned non-secret `/api/status`, returned the stable `invalid_audio` response for a malformed upload, and returned `/readyz` 503 because no recognition backend is configured. +- In-app browser smoke verified the page identity, meaningful first viewport, empty-upload recovery card, runtime-details disclosure, theme toggle, no horizontal overflow at 390px and 320px, and no current console errors. Microphone permission was not requested. - `sounddevice.query_devices()` listed 33 host devices, including input and output devices. No microphone recording or provider call was made. ### Verified remotely on merged `main` @@ -91,7 +92,7 @@ The merged [PR #8](https://github.com/icecold009/Audio-Recognition/pull/8) recor ### Not yet verified -- The current branch has not received a remote CI run; local checks are not equivalent to the full hosted matrix. +- The current feature branch has not been pushed, so its UI changes have no remote CI result yet; the successful remote run applies to the merged baseline at `86359d5`. - No complete real-world corpus has been recorded. - No credentialed provider comparison, recognition accuracy, p95 latency, catalog coverage, or live deployment smoke is claimed. - The in-app browser could not reach the elevated local listener, so no browser-engine/permission screenshot or real browser microphone capture is claimed. Source and mocked tests cover the recording fallback and cleanup paths. diff --git a/showcase/audio-recognition/evidence-checklist.md b/showcase/audio-recognition/evidence-checklist.md index cb6fe09..d67ddb7 100644 --- a/showcase/audio-recognition/evidence-checklist.md +++ b/showcase/audio-recognition/evidence-checklist.md @@ -6,15 +6,15 @@ | Evidence | Status | Use in showcase | |---|---|---| -| Repository source and documentation | Verified at `6eb46cf` | Supports the architecture, UI, API contract, limits, matcher behavior, evaluation gates, and limitations. | +| Repository source and documentation | Verified on feature branch `codex/showcase-ready-20260913` | Supports the architecture, UI, API contract, limits, matcher behavior, evaluation gates, and limitations. | | Existing FFT image | Verified: [`docs/screenshots/fft-output.png`](../../docs/screenshots/fft-output.png) | Technical visual only; caption it as “Diagnostic frequency spectrum; FFT is not the recognition algorithm.” | -| Merged-main CI | Verified remotely in [PR #8](https://github.com/icecold009/Audio-Recognition/pull/8) | Supports 190 tests per Python 3.10–3.12 job, 81% branch coverage, static/security gates, Render validation, and container smoke on merged main. | -| Current audit branch CI | Not reported by connected GitHub check | PR #11 is pushed and draft; local pytest (193), Ruff, and `git diff --check` passed, but local checks are not the hosted matrix/release gate. | +| Merged-main CI | Verified remotely at `86359d5` in [run 33418693705](https://github.com/icecold009/Audio-Recognition/actions/runs/33418693705) | Supports the current merged baseline's test, lint, coverage, dependency, secret, Render, and container gates. | +| Current feature branch UI smoke | Verified locally | In-app browser checks cover page identity, first viewport, theme toggle, runtime-details disclosure, empty-upload recovery, and 390px/320px overflow checks. The feature branch is not pushed yet. | | Live demo | Missing | Supply a public URL, then run a browser smoke check and label it live evidence. | -| Real product screenshots | Missing | Capture from the supported Flask app; no browser screenshot was available in this task. | +| Real product screenshots | Partially verified | Desktop and 390px/320px local captures were reviewed during this task; no screenshot files are committed yet. | | Real-world benchmark results | Missing by design | Assemble the legal corpus and run `scripts/benchmark.py`; do not infer quality from synthetic tests or CI. | | Credentialed provider smoke | Missing | Configure credentials outside Git and record a redacted, reproducible smoke result. | -| Fresh local test run | Verified with scoped launch | `.venv-pipeline\Scripts\python.exe -m pytest -q` reported `193 passed in 9.56s`; the initial non-elevated launch failed before collection because the configured Python process was inaccessible. | +| Fresh local test run | Verified with scoped launch | `.venv-pipeline\Scripts\python.exe -m coverage run --branch --source=shazam_project,web,scripts -m pytest -q` reported `193 passed in 12.23s`; source-scoped coverage reported 82% branch coverage, and the initial non-elevated launch failed before collection because the configured Python process was inaccessible. | | Host audio device enumeration | Verified, capture not attempted | `sounddevice.query_devices()` listed 33 devices, including microphone inputs and speaker outputs. This does not prove a real recording or provider recognition result. | ## Screenshot plan @@ -39,6 +39,7 @@ - The local backend uses spectral peaks, hash pairs, and time-offset consensus. - Merged-main remote CI recorded 190 tests per Python version, 81% branch coverage, lint/audit/secret gates, Render schema validation, and container smoke. - The benchmark tooling refuses incomplete result imports and keeps source audio/provider credentials out of the repository. +- The current browser polish is locally verified for the first viewport, theme toggle, runtime-details disclosure, empty-upload recovery, and narrow-screen overflow; microphone permission and real recording remain untested. ### Must remain labeled as missing or unverified @@ -46,13 +47,13 @@ - Provider superiority, catalog coverage, or robustness under noise/re-encoding. - Production uptime, user adoption, testimonials, or deployment readiness. - A live hosted URL or browser-engine compatibility. -- Current-branch remote CI status after `6eb46cf`. +- A remote CI result for the current feature branch; it has not been pushed. - Browser-engine permission state, real microphone capture, and device-to-provider recognition. ## Follow-up capture procedure 1. Make the supported pipeline interpreter launch without elevation, or document the scoped launch requirement for the developer environment. -2. Run the full release gate, coverage, compile, Ruff, and diff checks; record the exact commit and output. -3. Start `python web/app.py` in development mode and capture the home, status, invalid-upload, no-match, and matched-mock states. +2. Run the full release gate, coverage, compile, Ruff, and diff checks; record the exact feature-branch commit and output. +3. Start `python web/app.py` in development mode and capture the home, status, invalid-upload, no-match, and matched-mock states. Keep local captures separate from live evidence. 4. If a live URL is provided, verify the same journey against the deployed app and label screenshots as live rather than local. 5. Assemble the legally reusable evaluation corpus with track-level provenance/license notes, run the documented benchmark, review generated JSON/Markdown, and import results only if the completeness gate passes. diff --git a/showcase/audio-recognition/presentation-outline.md b/showcase/audio-recognition/presentation-outline.md index 9368c1c..0913fca 100644 --- a/showcase/audio-recognition/presentation-outline.md +++ b/showcase/audio-recognition/presentation-outline.md @@ -1,6 +1,6 @@ # DIY Shazam — presentation outline -**Status:** Source-verified showcase draft. The repository is a locally validated prototype, not a verified live production demo. This audit PR branch is `codex/audio-recognition-p0-audit` at `6eb46cf`; local pytest (193), Ruff, and diff checks passed, while the latest remote CI evidence applies to merged `origin/main`/PR #8 and no current-branch CI status entries are reported. +**Status:** Source-verified showcase draft. The repository is a locally validated prototype, not a verified live production demo. The current browser polish is on feature branch `codex/showcase-ready-20260913`; local pytest (193), 82% branch coverage, Ruff, compile, diff, and in-app browser smoke checks passed. Remote CI evidence applies to the merged baseline at `86359d5`; this feature branch is not pushed. **Audience:** General portfolio audience @@ -8,7 +8,7 @@ **Length:** 7 slides, approximately 2 minutes spoken -**Live demo:** None supplied; browser screenshots remain an evidence gap. +**Live demo:** None supplied; local browser screenshots were reviewed during verification but are not committed as showcase assets. ## Slide 1 — DIY Shazam @@ -20,7 +20,7 @@ - Flask browser UI plus CLI entry point - RapidAPI/Shazam, AcoustID, AudD, and local fingerprint backends -**Recommended visual:** A real screenshot of the Flask home screen showing the upload and recording controls. **[NEEDS EVIDENCE]** No live demo or current browser screenshot was supplied. Use `docs/screenshots/fft-output.png` only as a technical fallback, labeled as a diagnostic spectrum rather than a product screenshot. +**Recommended visual:** A real local screenshot of the Flask home screen showing the microphone-first CTA, concise runtime status, and upload alternative. Keep it labeled as local evidence; use `docs/screenshots/fft-output.png` only as a technical fallback, labeled as a diagnostic spectrum rather than a product screenshot. **Speaker notes:** “This is DIY Shazam: a song-recognition project built around a practical input pipeline and several interchangeable backends. The interesting part is not just calling an API; it is making microphone input, uploaded files, validation, fallback, and safe failure states behave consistently.” @@ -71,7 +71,7 @@ - Matched, no-match, invalid-input, rate-limit, and retry states - Light/dark theme and optional session-only history -**Recommended visual:** A three-panel screenshot sequence: empty home, result/error state, and history/details. **[NEEDS EVIDENCE]** Capture these from the actual Flask app after restoring a usable runtime or supplying a live URL. +**Recommended visual:** A three-panel screenshot sequence: empty home, result/error state, and history/details. The empty home and error state can be captured locally; matched/history details still require a deterministic mock or configured provider and must be labeled accordingly. **Speaker notes:** “The UI is intentionally small: the main job is to capture audio and communicate the next state clearly. The current branch also hardens browser recovery by making storage optional and stopping microphone tracks and visualizer resources when recording ends or fails.” @@ -116,7 +116,7 @@ flowchart LR - Merged-main branch coverage: 81%, above the 70% gate - Ruff, pip-audit, Gitleaks, Render schema, and container smoke passed remotely - Benchmark tooling exists, but no complete corpus or credentialed provider run is imported -- Current audit branch has local checks but is not yet separately remote-CI-verified +- Current feature branch UI changes are locally verified but not yet pushed for remote CI **Recommended visual:** A two-column “verified / still open” scorecard. Do not show invented accuracy or latency values. diff --git a/showcase/audio-recognition/presentation-script.md b/showcase/audio-recognition/presentation-script.md index 61eeaa6..51624d1 100644 --- a/showcase/audio-recognition/presentation-script.md +++ b/showcase/audio-recognition/presentation-script.md @@ -2,7 +2,7 @@ **Target length:** Approximately 2 minutes -**Status:** Evidence-backed script. Claims about merged-main CI remain separate from the current audit PR branch, which has local checks but no current-branch CI status entries. +**Status:** Evidence-backed script. Claims about merged-main CI remain separate from the current feature branch, whose browser polish is locally verified but not pushed. This is DIY Shazam, a song-recognition project that accepts either a microphone recording or an audio file and routes it through multiple matching backends. The project is designed as a practical end-to-end workflow: input handling, normalization, provider fallback, safe errors, and evaluation all matter as much as the happy-path match. @@ -12,6 +12,6 @@ The main journey is simple. In the Flask browser UI, the user uploads WAV, MP3, The dispatcher tries configured backends in order: RapidAPI/Shazam, AcoustID, AudD, and an educational local fingerprint index. The local path extracts spectral peaks, pairs them into hashes, and looks for consistent time offsets. FFT output is only a diagnostic visualization; it is not presented as the identification algorithm. Public responses stay deliberately small and use stable statuses such as `matched`, `no_match`, `not_configured`, `invalid_audio`, `rate_limited`, and `error`. -The engineering evidence is stronger than the recognition-quality evidence. Merged main has remote CI evidence showing 190 tests on each Python 3.10, 3.11, and 3.12 job, 81% branch coverage against a 70% gate, plus Ruff, dependency and secret scans, Render schema validation, and container smoke. The current audit branch `codex/audio-recognition-p0-audit` at `6eb46cf` also passes 193 local tests, Ruff, and `git diff --check`, but has not yet received a separate remote CI run. There is also no complete real-world benchmark yet: the repository still needs a legally reusable 30-track corpus, 90 microphone clips per backend, three recording conditions, provider configuration, and operator metadata before accuracy or latency can be claimed. +The engineering evidence is stronger than the recognition-quality evidence. Merged main at `86359d5` has a successful remote CI run showing the repository's test, coverage, Ruff, dependency, secret, Render, and container gates. The current feature branch also passes 193 local tests, 82% branch coverage, Ruff, Python compilation, and `git diff --check`, plus in-app browser checks for the first viewport, theme toggle, runtime disclosure, empty-upload recovery, and narrow-screen overflow. There is also no complete real-world benchmark yet: the repository still needs a legally reusable 30-track corpus, 90 microphone clips per backend, three recording conditions, provider configuration, and operator metadata before accuracy or latency can be claimed. -The main lesson is to make the evidence boundary visible. The next milestone is not another feature; it is a reproducible benchmark, browser smoke coverage, screenshot evidence, and a fresh CI run on this branch. Today, the honest status is a technically hardened, CI-validated project on merged main, with live-demo and real-world recognition quality still unverified. +The main lesson is to make the evidence boundary visible. The next milestone is not another backend; it is a reproducible benchmark, credentialed smoke, real recording coverage, and a fresh remote CI run after this feature branch is reviewed. Today, the honest status is a technically hardened, polished local-first project with merged-main CI evidence, while live-demo and real-world recognition quality remain unverified. diff --git a/tests/test_web.py b/tests/test_web.py index 6e2e40c..7d29eb5 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -35,6 +35,10 @@ def test_index_serves_the_flask_browser_application(self): self.assertEqual(response.status_code, 200) self.assertIn(b"DIY Shazam", response.data) self.assertIn(b"/static/app.js", response.data) + self.assertIn(b'class="record-button"', response.data) + self.assertIn(b'id="statusMessage"', response.data) + self.assertIn(b'id="statusDetails"', response.data) + self.assertIn(b'aria-live="polite"', response.data) def test_static_assets_are_served_by_flask(self): response = self.client.get("/static/style.css") @@ -47,6 +51,8 @@ def test_status_ui_uses_quota_mode_and_not_undefined_usage_fields(self): self.assertEqual(response.status_code, 200) self.assertIn(b"quota_mode", response.data) + self.assertIn(b"renderResultMessage", response.data) + self.assertIn(b"safeStreamingLink", response.data) self.assertNotIn(b"daily_used", response.data) self.assertNotIn(b"monthly_used", response.data) diff --git a/web/static/app.js b/web/static/app.js index 6668089..374aa6e 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -4,6 +4,12 @@ const recBtn = document.getElementById('recBtn'); const stopBtn = document.getElementById('stopBtn'); const resultDiv = document.getElementById('result'); const statusDiv = document.getElementById('status'); +const statusMessage = document.getElementById('statusMessage'); +const statusToggle = document.getElementById('statusToggle'); +const statusDetails = document.getElementById('statusDetails'); +const captureHint = document.getElementById('captureHint'); +const recBtnLabel = document.getElementById('recBtnLabel'); +const waveform = document.querySelector('.waveform'); let mediaRecorder; let chunks = []; @@ -104,16 +110,33 @@ function buildStreamingLink(title, artist) { return `https://open.spotify.com/search/${q}`; } +function safeStreamingLink(value, title, artist) { + try { + const url = new URL(value); + if (url.protocol === 'https:') return url.href; + } catch { + // Fall back to a safe, generated Spotify search URL. + } + return buildStreamingLink(title, artist); +} + function setMicLabel() { + const label = isProcessing ? 'Identifying…' : (isListening ? 'Listening…' : 'Start Recording'); + if (recBtnLabel) recBtnLabel.textContent = label; if (recBtn) { - recBtn.textContent = isProcessing ? 'Identifying…' : (isListening ? 'Listening…' : 'Start Recording'); - recBtn.setAttribute( - 'aria-label', - isProcessing ? 'Identifying song' : (isListening ? 'Stop listening' : 'Identify song') - ); + recBtn.setAttribute('aria-label', isProcessing ? 'Identifying song' : (isListening ? 'Stop listening' : 'Identify song')); + recBtn.setAttribute('aria-pressed', String(isListening)); + recBtn.setAttribute('aria-busy', String(isProcessing)); + recBtn.classList.toggle('is-listening', isListening); + recBtn.classList.toggle('is-processing', isProcessing); } if (stopBtn) { - stopBtn.setAttribute('aria-label', 'Stop recording'); + stopBtn.setAttribute('aria-label', isListening ? 'Stop recording' : 'Stop recording'); + } + if (captureHint) { + captureHint.textContent = isProcessing + ? 'Identifying your recording…' + : (isListening ? 'Listening… stop when you have enough audio.' : 'Tap the microphone to listen for up to 10 seconds.'); } } @@ -123,6 +146,7 @@ function setSubmittingState(loading) { uploadBtn.disabled = loading || isListening; recBtn.disabled = loading || (mediaRecorder && mediaRecorder.state !== 'inactive'); stopBtn.disabled = !loading && (!mediaRecorder || mediaRecorder.state === 'inactive'); + resultDiv.setAttribute('aria-busy', String(loading)); setMicLabel(); } @@ -135,6 +159,10 @@ function setListeningState(active) { recBtn.disabled = false; stopBtn.disabled = true; } + if (waveform) { + waveform.classList.toggle('is-active', active); + waveform.setAttribute('aria-label', active ? 'Live microphone waveform' : 'Idle waveform visualizer'); + } setMicLabel(); } @@ -208,77 +236,86 @@ function showDetailModal(item) { const overlay = document.createElement('div'); overlay.id = 'detail-modal'; - overlay.style.position = 'fixed'; - overlay.style.inset = '0'; - overlay.style.background = 'rgba(0,0,0,0.55)'; - overlay.style.display = 'flex'; - overlay.style.alignItems = 'center'; - overlay.style.justifyContent = 'center'; - overlay.style.padding = '16px'; - overlay.style.zIndex = '9999'; + overlay.className = 'detail-modal-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-labelledby', 'detail-modal-title'); + overlay.tabIndex = -1; + + const previousFocus = document.activeElement; + const closeModal = () => { + overlay.remove(); + if (previousFocus && typeof previousFocus.focus === 'function') previousFocus.focus(); + }; const modal = document.createElement('div'); - modal.className = 'panel'; - modal.style.width = 'min(100%, 520px)'; - modal.style.maxHeight = '90vh'; - modal.style.overflowY = 'auto'; - modal.style.display = 'grid'; - modal.style.gap = '12px'; + modal.className = 'panel detail-modal'; const closeBtn = document.createElement('button'); + closeBtn.className = 'detail-close'; + closeBtn.type = 'button'; closeBtn.textContent = 'Close'; - closeBtn.style.width = 'fit-content'; - closeBtn.addEventListener('click', () => overlay.remove()); + closeBtn.addEventListener('click', closeModal); + const header = document.createElement('div'); + header.className = 'detail-modal-header'; const heading = document.createElement('h2'); + heading.id = 'detail-modal-title'; heading.textContent = 'Song Details'; - - const title = document.createElement('div'); - title.textContent = `Title: ${item.title || '(unknown)'}`; - - const artist = document.createElement('div'); - artist.textContent = `Artist: ${item.artist || '(unknown)'}`; - - const album = document.createElement('div'); - album.textContent = `Album: ${item.album || '(unknown)'}`; - - const genre = document.createElement('div'); - genre.textContent = `Genre: ${item.genre || '(not available)'}`; - - const releaseDate = document.createElement('div'); - releaseDate.textContent = `Release Date: ${item.release_date || '(not available)'}`; + header.appendChild(heading); + header.appendChild(closeBtn); + + const fields = document.createElement('div'); + fields.className = 'detail-fields'; + const appendField = (label, value) => { + const field = document.createElement('div'); + field.className = 'detail-field'; + const fieldLabel = document.createElement('span'); + fieldLabel.className = 'detail-field-label'; + fieldLabel.textContent = label; + const fieldValue = document.createElement('span'); + fieldValue.className = 'detail-field-value'; + fieldValue.textContent = value; + field.appendChild(fieldLabel); + field.appendChild(fieldValue); + fields.appendChild(field); + }; const link = document.createElement('a'); - link.href = item.streaming_url || buildStreamingLink(item.title, item.artist); + link.className = 'button-link'; + link.href = safeStreamingLink(item.streaming_url, item.title, item.artist); link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = 'Open in Spotify'; - modal.appendChild(closeBtn); - modal.appendChild(heading); - if (item.image) { const img = document.createElement('img'); img.src = item.image; img.alt = item.title || 'Album art'; - img.style.maxWidth = '220px'; - img.style.borderRadius = '12px'; + img.className = 'detail-art'; modal.appendChild(img); } - modal.appendChild(title); - modal.appendChild(artist); - modal.appendChild(album); - modal.appendChild(genre); - modal.appendChild(releaseDate); + appendField('Title', item.title || '(unknown)'); + appendField('Artist', item.artist || '(unknown)'); + appendField('Album', item.album || '(unknown)'); + appendField('Genre', item.genre || '(not available)'); + appendField('Release date', item.release_date || '(not available)'); + + modal.prepend(header); + modal.appendChild(fields); modal.appendChild(link); overlay.appendChild(modal); overlay.addEventListener('click', (e) => { - if (e.target === overlay) overlay.remove(); + if (e.target === overlay) closeModal(); + }); + overlay.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeModal(); }); document.body.appendChild(overlay); + closeBtn.focus(); } function renderSettings() { @@ -287,23 +324,21 @@ function renderSettings() { const section = document.createElement('section'); section.id = 'settings-section'; - section.className = 'panel stack'; - section.style.marginTop = '20px'; + section.className = 'panel settings-panel'; const heading = document.createElement('h2'); heading.textContent = 'Privacy'; const label = document.createElement('label'); - label.style.display = 'flex'; - label.style.alignItems = 'center'; - label.style.gap = '10px'; - label.style.fontWeight = '600'; + label.className = 'toggle-row'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; + checkbox.className = 'toggle-input'; checkbox.checked = isHistoryEnabled(); const text = document.createElement('span'); + text.className = 'toggle-copy'; text.textContent = 'Save history in this session'; checkbox.addEventListener('change', () => { @@ -318,6 +353,7 @@ function renderSettings() { label.appendChild(text); const note = document.createElement('div'); + note.className = 'settings-note'; note.textContent = 'When off, recognized songs will not be stored in session history.'; section.appendChild(heading); @@ -334,8 +370,7 @@ function renderHistory() { const section = document.createElement('section'); section.id = 'history-section'; - section.className = 'stack'; - section.style.marginTop = '20px'; + section.className = 'history-section'; const h2 = document.createElement('h2'); h2.className = 'section-heading'; @@ -353,33 +388,45 @@ function renderHistory() { } else { history.forEach((item, index) => { const card = document.createElement('div'); - card.className = 'panel'; - card.style.display = 'grid'; - card.style.gap = '6px'; - card.style.marginBottom = '12px'; + card.className = 'history-card'; const title = document.createElement('div'); + title.className = 'history-title'; title.textContent = `Song: ${item.title || '(unknown)'}`; const artist = document.createElement('div'); + artist.className = 'history-artist'; artist.textContent = `Artist: ${item.artist || '(unknown)'}`; const album = document.createElement('div'); + album.className = 'history-artist'; album.textContent = `Album: ${item.album || '(unknown)'}`; - const time = document.createElement('div'); + const time = document.createElement('time'); + time.className = 'history-time'; + const timestamp = new Date(item.timestamp); + if (!Number.isNaN(timestamp.getTime())) time.dateTime = timestamp.toISOString(); time.textContent = `Recognized: ${new Date(item.timestamp).toLocaleString()}`; const actions = document.createElement('div'); - actions.style.display = 'flex'; - actions.style.gap = '10px'; - actions.style.flexWrap = 'wrap'; + actions.className = 'history-actions'; + + const copy = document.createElement('div'); + copy.className = 'history-copy'; + copy.appendChild(title); + copy.appendChild(artist); + copy.appendChild(album); + copy.appendChild(time); const viewBtn = document.createElement('button'); + viewBtn.type = 'button'; + viewBtn.className = 'button-secondary'; viewBtn.textContent = 'View Details'; viewBtn.addEventListener('click', () => showDetailModal(item)); const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'button-secondary'; delBtn.textContent = 'Remove'; delBtn.addEventListener('click', () => { const updated = loadHistory(); @@ -391,20 +438,21 @@ function renderHistory() { actions.appendChild(viewBtn); actions.appendChild(delBtn); - card.appendChild(title); - card.appendChild(artist); - card.appendChild(album); - card.appendChild(time); - if (item.image) { const img = document.createElement('img'); img.src = item.image; img.alt = item.title || 'Album art'; - img.style.maxWidth = '120px'; - img.style.borderRadius = '12px'; + img.className = 'history-art'; card.appendChild(img); + } else { + const placeholder = document.createElement('div'); + placeholder.className = 'history-art history-art-placeholder'; + placeholder.setAttribute('aria-hidden', 'true'); + placeholder.textContent = '♪'; + card.appendChild(placeholder); } + card.appendChild(copy); card.appendChild(actions); section.appendChild(card); }); @@ -417,31 +465,90 @@ async function refreshStatus() { try { const res = await fetch('/api/status'); const s = await res.json(); - statusDiv.textContent = - `RapidAPI: ${s.rapidapi_configured ? 'configured' : 'not configured'}; ` + - `AcoustID: ${s.acoustid_configured ? 'configured' : 'not configured'}; ` + - `fpcalc on PATH: ${s.fpcalc_on_path ? 'yes' : 'no'}; ` + - `FP_CALC_PATH exists: ${s.fpcalc_path_exists ? 'yes' : 'no'}; ` + - `ffmpeg on PATH: ${s.ffmpeg_on_path ? 'yes' : 'no'}; ` + - `AudD token: ${s.audd_configured ? 'configured' : 'not configured'}; ` + - `Supabase: ${s.supabase_configured ? 'configured' : 'not configured'}; ` + - `Quota: ${s.quota_mode || 'unknown'}; ` + - `Production-grade: ${s.production_grade_quotas_enabled ? 'enabled' : 'disabled'}; ` + - `Limits: ${s.daily_limit}/day, ${s.monthly_limit}/month; ` + - `Cooldown: ${s.cooldown_seconds}s`; + const backends = [ + ['RapidAPI / Shazam', s.rapidapi_configured], + ['AcoustID', s.acoustid_configured], + ['AudD', s.audd_configured], + ['Local fingerprint index', s.local_index_configured] + ]; + const configuredBackends = backends.filter(([, configured]) => configured).map(([name]) => name); + const quotaMode = s.quota_mode || 'unknown'; + const quotaLabel = s.production_grade_quotas_enabled ? 'Production quota' : `Development quota (${quotaMode})`; + + statusDiv.dataset.state = configuredBackends.length ? 'ready' : 'idle'; + statusMessage.textContent = configuredBackends.length + ? `${configuredBackends.length} recognition backend${configuredBackends.length === 1 ? '' : 's'} ready` + : 'Development mode · add a recognition backend to identify audio'; + + statusDetails.replaceChildren(); + const details = [ + ['Backends', configuredBackends.length ? configuredBackends.join(', ') : 'None configured'], + ['Audio tools', `FFmpeg ${s.ffmpeg_on_path ? 'ready' : 'missing'} · fpcalc ${s.fpcalc_on_path ? 'ready' : 'missing'}`], + ['Quota', `${quotaLabel} · ${s.daily_limit}/day · ${s.monthly_limit}/month`], + ['Audio limits', `${s.min_audio_seconds}s–${s.max_audio_seconds}s · ${Math.round(s.max_upload_bytes / (1024 * 1024))} MiB upload`] + ]; + details.forEach(([label, value]) => { + const row = document.createElement('div'); + row.className = 'status-detail-row'; + const name = document.createElement('span'); + name.textContent = label; + const detail = document.createElement('strong'); + detail.textContent = value; + row.appendChild(name); + row.appendChild(detail); + statusDetails.appendChild(row); + }); } catch (err) { - statusDiv.innerText = 'Failed to fetch status: ' + err; + statusDiv.dataset.state = 'error'; + statusMessage.textContent = 'Runtime status unavailable'; + statusDetails.replaceChildren(); } } +function renderResultMessage(title, copy, tone = 'info', icon = 'i', code = '') { + resultDiv.replaceChildren(); + resultDiv.dataset.state = tone; + + const message = document.createElement('div'); + message.className = 'result-message'; + message.dataset.tone = tone; + + const resultIcon = document.createElement('span'); + resultIcon.className = 'result-icon'; + resultIcon.setAttribute('aria-hidden', 'true'); + resultIcon.textContent = icon; + + const content = document.createElement('div'); + content.className = 'result-content'; + const titleNode = document.createElement('div'); + titleNode.className = 'result-title'; + titleNode.textContent = title; + const copyNode = document.createElement('div'); + copyNode.className = 'result-copy'; + copyNode.textContent = copy; + content.appendChild(titleNode); + content.appendChild(copyNode); + if (code) { + const codeNode = document.createElement('div'); + codeNode.className = 'result-code'; + codeNode.textContent = code; + content.appendChild(codeNode); + } + + message.appendChild(resultIcon); + message.appendChild(content); + resultDiv.appendChild(message); +} + function renderRetryButton() { const retryBtn = document.createElement('button'); + retryBtn.className = 'button-secondary retry-button'; + retryBtn.type = 'button'; retryBtn.textContent = 'Retry'; - retryBtn.style.marginTop = '10px'; retryBtn.disabled = isSubmitting; retryBtn.addEventListener('click', () => { if (lastFile) postFile(lastFile); - else resultDiv.innerText = 'Nothing to retry yet.'; + else renderResultMessage('Nothing to retry yet', 'Choose an audio clip to begin.', 'info', 'i'); }); resultDiv.appendChild(retryBtn); } @@ -454,7 +561,7 @@ async function postFile(file) { const fd = new FormData(); fd.append('file', file, file.name || 'upload.audio'); - resultDiv.innerText = 'Uploading...'; + renderResultMessage('Preparing your audio…', 'Validating a bounded clip and checking the configured recognition backends.', 'info', '…'); try { const res = await fetch('/api/match', { method: 'POST', body: fd }); @@ -462,7 +569,7 @@ async function postFile(file) { renderResult(data); await refreshStatus(); } catch (err) { - resultDiv.innerText = 'Upload error: ' + err; + renderResultMessage('Upload failed', 'The audio could not reach the recognition service. Check your connection and try again.', 'error', '!'); renderRetryButton(); } finally { setSubmittingState(false); @@ -471,49 +578,96 @@ async function postFile(file) { function renderResult(data) { if (!data) { - resultDiv.innerText = 'No response'; + renderResultMessage('No response received', 'The recognition service returned no usable response.', 'error', '!'); renderRetryButton(); return; } if (data.status === 'rate_limited') { - resultDiv.innerText = data.error || 'Too many requests. Please wait and try again.'; + renderResultMessage('Please wait before trying again', data.error || 'Too many requests. Please wait and try again.', 'error', '⏱'); return; } if (data.status === 'not_configured') { - resultDiv.innerText = data.error || 'No recognition backend is configured.'; + renderResultMessage('Recognition is not configured yet', data.error || 'Add a recognition provider or local fingerprint index to identify audio.', 'info', 'i'); renderRetryButton(); return; } if (data.status === 'invalid_audio') { - resultDiv.innerText = `${data.error_code || 'invalid_audio'}: ${data.error || 'Audio was rejected.'}`; + renderResultMessage('That audio clip could not be used', data.error || 'Audio was rejected. Choose a supported clip and try again.', 'error', '!', data.error_code || 'invalid_audio'); renderRetryButton(); return; } if (data.status === 'error') { - resultDiv.innerText = 'Server error: ' + (data.error || 'unknown'); + renderResultMessage('Recognition hit a problem', data.error || 'The server could not complete recognition.', 'error', '!'); renderRetryButton(); return; } if (data.status === 'no_match') { - resultDiv.innerText = 'No match found.'; + renderResultMessage('No match found', 'Try a clearer clip with less background noise, or move closer to the source.', 'info', '♪'); renderRetryButton(); return; } - resultDiv.innerHTML = ''; + resultDiv.replaceChildren(); + resultDiv.dataset.state = 'matched'; - const title = document.createElement('div'); - title.textContent = `Song: ${data.title || '(unknown)'}`; + const card = document.createElement('article'); + card.className = 'match-card'; - const artist = document.createElement('div'); - artist.textContent = `Artist: ${data.artist || '(unknown)'}`; + const main = document.createElement('div'); + main.className = 'match-card-main'; + let artwork; + if (data.image) { + artwork = document.createElement('img'); + artwork.src = data.image; + artwork.alt = data.title || 'Album art'; + artwork.className = 'match-art'; + artwork.loading = 'lazy'; + artwork.referrerPolicy = 'no-referrer'; + } else { + artwork = document.createElement('div'); + artwork.className = 'match-art match-art-placeholder'; + artwork.setAttribute('role', 'img'); + artwork.setAttribute('aria-label', 'Album art unavailable'); + artwork.textContent = '♪'; + } + + const copy = document.createElement('div'); + copy.className = 'match-copy'; + const matchLabel = document.createElement('div'); + matchLabel.className = 'match-label'; + matchLabel.textContent = 'Match found'; + const title = document.createElement('h3'); + title.className = 'match-title'; + title.textContent = data.title || '(unknown)'; + const artist = document.createElement('div'); + artist.className = 'match-artist'; + artist.textContent = data.artist || '(unknown)'; const album = document.createElement('div'); - album.textContent = `Album: ${data.album || '(unknown)'}`; + album.className = 'match-album'; + album.textContent = data.album || '(unknown)'; + copy.appendChild(matchLabel); + copy.appendChild(title); + copy.appendChild(artist); + copy.appendChild(album); + + main.appendChild(artwork); + main.appendChild(copy); const detailBtn = document.createElement('button'); - detailBtn.textContent = 'View Details'; - detailBtn.style.marginTop = '10px'; + detailBtn.className = 'button-secondary'; + detailBtn.type = 'button'; + detailBtn.textContent = 'View details'; + + const streamingLink = document.createElement('a'); + streamingLink.className = 'button-link'; + streamingLink.href = safeStreamingLink(data.streaming_url, data.title, data.artist); + streamingLink.target = '_blank'; + streamingLink.rel = 'noopener noreferrer'; + streamingLink.textContent = 'Open in Spotify'; + + const actions = document.createElement('div'); + actions.className = 'match-actions'; const item = { title: data.title || '', @@ -522,24 +676,16 @@ function renderResult(data) { genre: data.genre || '', release_date: data.release_date || '', image: data.image || '', - streaming_url: data.streaming_url || buildStreamingLink(data.title, data.artist), + streaming_url: safeStreamingLink(data.streaming_url, data.title, data.artist), timestamp: Date.now() }; detailBtn.addEventListener('click', () => showDetailModal(item)); - - resultDiv.appendChild(title); - resultDiv.appendChild(artist); - resultDiv.appendChild(album); - - if (data.image) { - const img = document.createElement('img'); - img.src = data.image; - img.alt = data.title || 'Album art'; - resultDiv.appendChild(img); - } - - resultDiv.appendChild(detailBtn); + actions.appendChild(detailBtn); + actions.appendChild(streamingLink); + card.appendChild(main); + card.appendChild(actions); + resultDiv.appendChild(card); addToHistory(item); renderHistory(); @@ -551,10 +697,19 @@ renderSettings(); renderHistory(); setMicLabel(); +if (statusToggle) { + statusToggle.addEventListener('click', () => { + const expanded = statusToggle.getAttribute('aria-expanded') === 'true'; + statusToggle.setAttribute('aria-expanded', String(!expanded)); + statusDetails.hidden = expanded; + statusToggle.textContent = expanded ? 'View details' : 'Hide details'; + }); +} + uploadBtn.addEventListener('click', () => { const f = fileInput.files[0]; if (!f) { - resultDiv.innerText = 'Select a file first.'; + renderResultMessage('Select an audio file first', 'Choose a supported clip below, then try the upload again.', 'info', '↑'); return; } postFile(f); @@ -564,7 +719,7 @@ recBtn.addEventListener('click', async () => { if (isSubmitting || isListening) return; if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { - resultDiv.innerText = 'Microphone not supported in this browser.'; + renderResultMessage('Microphone unavailable', 'This browser does not expose microphone capture. Use an audio file instead.', 'error', '!'); renderRetryButton(); return; } @@ -598,7 +753,7 @@ recBtn.addEventListener('click', async () => { chunks = []; if (blob.size === 0) { - resultDiv.innerText = 'No audio recorded.'; + renderResultMessage('No audio was recorded', 'Try again and keep the microphone permission active while listening.', 'error', '!'); renderRetryButton(); recBtn.disabled = false; stopBtn.disabled = true; @@ -617,7 +772,7 @@ recBtn.addEventListener('click', async () => { mediaRecorder.start(1000); setListeningState(true); - resultDiv.innerText = 'Recording... (auto-stops after 10 seconds)'; + renderResultMessage('Listening…', 'The waveform is live. Stop when you have enough audio, or wait for the 10-second limit.', 'info', '◉'); stopTimer = setTimeout(() => { if (mediaRecorder && mediaRecorder.state !== 'inactive') { @@ -625,7 +780,7 @@ recBtn.addEventListener('click', async () => { } recBtn.disabled = false; stopBtn.disabled = true; - resultDiv.innerText = 'Recording stopped automatically after 10 seconds.'; + if (captureHint) captureHint.textContent = 'Recording stopped automatically after 10 seconds.'; }, 10000); } catch (err) { if (mediaRecorder && mediaRecorder.state !== 'inactive') { @@ -635,7 +790,7 @@ recBtn.addEventListener('click', async () => { stopMediaStream(); stopVisualizer(); setListeningState(false); - resultDiv.innerText = 'Microphone error: ' + err; + renderResultMessage('Microphone access failed', 'Allow microphone access or choose an audio file instead.', 'error', '!'); renderRetryButton(); recBtn.disabled = false; stopBtn.disabled = true; diff --git a/web/static/style.css b/web/static/style.css index 18bf8ce..d7411fa 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -467,6 +467,56 @@ button:focus-visible { } } +#status { + margin-bottom: 0; + padding: 0; + color: var(--color-text-muted); + background: color-mix(in srgb, var(--color-surface) 92%, transparent); + border: 1px solid var(--color-border); + border-radius: 24px; + font-family: inherit; + font-size: inherit; + line-height: 1.6; +} + +#recBtn.record-button { + width: 116px; + height: 116px; + padding: 0; + background: var(--color-primary); + border: 0; + border-radius: 50%; +} + +#stopBtn.secondary-button { + min-height: 44px; + padding: 0.68rem 1.1rem; + color: var(--color-text); + background: var(--color-surface-2); + border: 1px solid var(--color-border); + box-shadow: none; +} + +#stopBtn.secondary-button:disabled { + background: var(--color-surface-2); + border-color: var(--color-border); + opacity: 0.4; +} + +.match-art-placeholder, +.history-art-placeholder { + display: grid; + place-items: center; + color: var(--color-primary); + background: linear-gradient(145deg, var(--color-surface-offset), var(--color-surface-2)); + font-family: 'Sora', sans-serif; + font-size: 1.8rem; +} + +.history-art-placeholder { + font-size: 1.25rem; +} + @media (max-width: 480px) { .app-wrap { padding: var(--space-4) var(--space-3) var(--space-12); @@ -492,4 +542,822 @@ button:focus-visible { width: 88px; height: 88px; } -} \ No newline at end of file +} + +/* Showcase polish: keep the MVP calm, legible, and useful before a provider is configured. */ +:root { + --color-bg: #0a0d12; + --color-surface: #111720; + --color-surface-2: #172231; + --color-surface-offset: #1c2938; + --color-divider: #263444; + --color-border: #2c4054; + --color-text: #f2f6fb; + --color-text-muted: #aab8c7; + --color-text-faint: #718295; + --color-primary: #35a9ff; + --color-primary-hover: #60baff; + --color-primary-active: #1d8cda; + --color-primary-glow: rgba(53, 169, 255, 0.2); + --color-success: #45d483; + --color-warning: #f4bc5d; + --color-error: #ff7777; + --color-wave-1: #48b6ff; + --color-wave-2: rgba(72, 182, 255, 0.25); + --shadow-panel: 0 18px 44px rgba(0, 0, 0, 0.24); + --text-xl: clamp(2rem, 1.62rem + 1.8vw, 3.4rem); +} + +[data-theme="light"] { + --color-bg: #f4f8fc; + --color-surface: #fff; + --color-surface-2: #f1f6fb; + --color-surface-offset: #e7eef6; + --color-divider: #d7e1eb; + --color-border: #c4d3e1; + --color-text: #122031; + --color-text-muted: #516477; + --color-text-faint: #718497; + --color-primary: #087ec5; + --color-primary-hover: #096ca8; + --color-primary-active: #075883; + --color-primary-glow: rgba(8, 126, 197, 0.16); + --color-wave-1: #168dd0; + --color-wave-2: rgba(22, 141, 208, 0.22); + --shadow-panel: 0 18px 44px rgba(36, 70, 102, 0.13); +} + +html { + scroll-behavior: smooth; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +button:focus-visible, +input:focus-visible, +a:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 3px; +} + +.skip-link { + position: fixed; + z-index: 10000; + top: 12px; + left: 12px; + padding: 8px 12px; + color: #fff; + background: var(--color-primary-active); + border-radius: 8px; + transform: translateY(-180%); + transition: transform 180ms ease; +} + +.skip-link:focus { + transform: translateY(0); +} + +.brand-copy { + display: grid; + gap: 1px; +} + +.brand-tagline, +.section-note, +.field-help, +.waveform-label { + color: var(--color-text-faint); + font-size: var(--text-xs); +} + +.brand-tagline { + letter-spacing: 0.02em; +} + +main { + display: grid; + gap: var(--space-12); +} + +.hero { + padding-top: clamp(38px, 8vw, 80px); +} + +.hero-copy { + max-width: 720px; + gap: var(--space-3); +} + +.eyebrow, +.section-label { + color: var(--color-primary); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.page-title { + max-width: 12ch; + font-size: var(--text-xl); + letter-spacing: -0.06em; +} + +.page-subtitle { + max-width: 58ch; + font-size: var(--text-base); +} + +.status-card, +.panel { + background: color-mix(in srgb, var(--color-surface) 92%, transparent); + border: 1px solid var(--color-border); + border-radius: 24px; + box-shadow: var(--shadow-panel); +} + +.status-card { + overflow: hidden; +} + +.status-summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-height: 58px; + padding: 12px 16px; +} + +.status-dot { + width: 9px; + height: 9px; + background: var(--color-warning); + border-radius: 50%; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-warning) 14%, transparent); +} + +.status-card[data-state="ready"] .status-dot { + background: var(--color-success); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-success) 14%, transparent); +} + +.status-card[data-state="error"] .status-dot { + background: var(--color-error); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-error) 14%, transparent); +} + +.status-message { + min-width: 0; + overflow: hidden; + color: var(--color-text-muted); + font-size: var(--text-sm); + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-button { + padding: 8px 0; + color: var(--color-primary); + background: transparent; + font-size: var(--text-xs); + font-weight: 700; + white-space: nowrap; +} + +.text-button:hover { + color: var(--color-primary-hover); + text-decoration: underline; + text-underline-offset: 3px; +} + +.status-details { + display: grid; + gap: 8px; + padding: 0 16px 16px; + color: var(--color-text-muted); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: var(--text-xs); +} + +.status-details[hidden] { + display: none; +} + +.status-detail-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding-top: 8px; + border-top: 1px solid var(--color-divider); +} + +.status-detail-row strong { + color: var(--color-text); + font-weight: 500; + text-align: right; +} + +.cta-shell { + padding: clamp(24px, 5vw, 48px); +} + +.capture-layout { + display: grid; + gap: 24px; +} + +.capture-primary { + display: grid; + justify-items: center; + gap: 12px; + text-align: center; +} + +.record-button { + position: relative; + display: grid; + width: 116px; + height: 116px; + place-items: center; + color: #fff; + background: var(--color-primary); + border-radius: 50%; + box-shadow: 0 0 0 0 var(--color-primary-glow), 0 12px 28px rgba(0, 0, 0, 0.22); + transition: background 180ms ease, box-shadow 180ms ease, transform 180ms ease; + animation: idle-pulse 3s ease-in-out infinite; +} + +.record-button::before { + position: absolute; + inset: -10px; + border: 1px solid color-mix(in srgb, var(--color-primary) 28%, transparent); + border-radius: inherit; + content: ''; +} + +.record-button:hover:not(:disabled) { + background: var(--color-primary-hover); + box-shadow: 0 0 0 10px var(--color-primary-glow), 0 16px 34px rgba(0, 0, 0, 0.25); + transform: translateY(-2px); +} + +.record-button:active:not(:disabled) { + background: var(--color-primary-active); + transform: translateY(0) scale(0.98); +} + +.record-button.is-listening { + animation: listening-ring 1.15s ease-in-out infinite; +} + +.record-button.is-processing { + animation: processing-pulse 1.1s ease-in-out infinite; +} + +.record-button:disabled { + opacity: 0.72; +} + +.record-button svg { + width: 44px; + height: 44px; + fill: currentColor; +} + +.capture-hint { + max-width: 38ch; + min-height: 1.6em; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.secondary-button, +.button-secondary { + min-height: 44px; + padding: 0.68rem 1.1rem; + color: var(--color-text); + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: 999px; + font-size: var(--text-sm); + font-weight: 700; +} + +.secondary-button:hover:not(:disabled), +.button-secondary:hover:not(:disabled) { + background: var(--color-surface-offset); + border-color: var(--color-primary); +} + +.capture-divider { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + color: var(--color-text-faint); + font-size: var(--text-xs); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.capture-divider::before, +.capture-divider::after { + height: 1px; + background: var(--color-divider); + content: ''; +} + +.upload-field, +.waveform-wrap { + display: grid; + gap: 8px; +} + +.upload-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} + +input[type="file"] { + width: 100%; + min-width: 0; + padding: 0.65rem; + color: var(--color-text-muted); + background: var(--color-surface-offset); + border: 1px solid var(--color-border); + border-radius: 12px; + font-size: var(--text-sm); +} + +input[type="file"]::file-selector-button { + margin-right: 8px; + padding: 0.46rem 0.72rem; + color: #fff; + background: var(--color-primary-active); + border: 0; + border-radius: 8px; + font-size: var(--text-xs); + font-weight: 700; + cursor: pointer; +} + +#uploadBtn { + min-height: 44px; + padding: 0.68rem 1rem; + color: #fff; + background: var(--color-primary-active); + border-radius: 999px; + font-size: var(--text-sm); + font-weight: 700; + white-space: nowrap; +} + +#uploadBtn:hover:not(:disabled) { + background: var(--color-primary); +} + +.waveform-label { + text-align: center; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.waveform { + height: 46px; + align-items: center; +} + +.wave-bar { + height: 10px; + background: var(--color-wave-2); + transition: height 80ms ease, opacity 120ms ease, background 180ms ease; +} + +.waveform.is-active .wave-bar { + background: var(--color-wave-1); +} + +.result-section { + min-width: 0; +} + +.section-heading-row { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.section-heading-row > div { + display: grid; + gap: 4px; +} + +#result { + justify-items: stretch; + min-width: 0; + text-align: left; +} + +.result-message, +.match-card, +.history-card { + min-width: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 16px; +} + +.result-message { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 20px; +} + +.result-message[data-tone="error"] { + border-color: color-mix(in srgb, var(--color-error) 45%, var(--color-border)); +} + +.result-message[data-tone="success"] { + border-color: color-mix(in srgb, var(--color-success) 45%, var(--color-border)); +} + +.result-icon { + display: grid; + width: 30px; + height: 30px; + place-items: center; + color: var(--color-primary); + background: var(--color-primary-glow); + border-radius: 50%; + font-weight: 800; +} + +.result-message[data-tone="error"] .result-icon { + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 14%, transparent); +} + +.result-content { + display: grid; + min-width: 0; + gap: 4px; +} + +.result-title { + font-weight: 700; +} + +.result-copy, +.result-code { + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.result-code { + color: var(--color-text-faint); + font-family: 'JetBrains Mono', ui-monospace, monospace; +} + +.retry-button { + justify-self: start; + margin-top: 8px; +} + +.match-card { + display: grid; + gap: 20px; + padding: clamp(18px, 4vw, 28px); + animation: result-enter 220ms ease-out both; +} + +.match-card-main { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 20px; + align-items: center; +} + +.match-art, +.history-art { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + background: var(--color-surface-offset); + border-radius: 12px; +} + +.match-copy { + display: grid; + min-width: 0; + gap: 4px; +} + +.match-label { + color: var(--color-success); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.match-title { + overflow-wrap: anywhere; + font-family: 'Sora', sans-serif; + font-size: var(--text-lg); + font-weight: 700; + line-height: 1.2; +} + +.match-artist, +.match-album { + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.match-actions, +.history-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.button-link { + display: inline-flex; + min-height: 44px; + align-items: center; + padding: 0.68rem 1.1rem; + color: #fff; + background: var(--color-primary-active); + border-radius: 999px; + font-size: var(--text-sm); + font-weight: 700; + text-decoration: none; +} + +.button-link:hover { + color: #fff; + background: var(--color-primary); +} + +.settings-panel, +.history-section { + margin-top: 32px; +} + +.settings-panel { + display: grid; + gap: 12px; + padding: 20px; +} + +.settings-panel h2 { + font-family: 'Sora', sans-serif; + font-size: var(--text-lg); +} + +.toggle-row { + display: flex; + align-items: center; + gap: 12px; + color: var(--color-text); + cursor: pointer; +} + +.toggle-row input { + width: 18px; + height: 18px; + accent-color: var(--color-primary); +} + +.toggle-copy, +.history-title { + font-size: var(--text-sm); + font-weight: 700; +} + +.settings-note, +.history-artist, +.history-time { + color: var(--color-text-muted); + font-size: var(--text-xs); +} + +.history-section { + display: grid; + gap: 12px; +} + +.history-card { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 12px; + transition: background 180ms ease, border-color 180ms ease; +} + +.history-card:hover { + background: var(--color-surface-2); + border-color: var(--color-primary); +} + +.history-art { + width: 56px; + border-radius: 8px; +} + +.history-copy { + display: grid; + min-width: 0; + gap: 1px; +} + +.history-title, +.history-artist, +.history-time { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-actions .button-secondary { + min-height: 38px; + padding: 0.46rem 0.78rem; + font-size: var(--text-xs); +} + +.detail-modal-overlay { + position: fixed; + z-index: 9999; + inset: 0; + display: grid; + place-items: center; + padding: 16px; + background: rgba(4, 8, 12, 0.72); + backdrop-filter: blur(8px); +} + +.detail-modal { + display: grid; + width: min(100%, 520px); + max-height: 90vh; + gap: 16px; + overflow-y: auto; + padding: clamp(20px, 5vw, 32px); + animation: result-enter 180ms ease-out both; +} + +.detail-modal-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; +} + +.detail-modal h2 { + font-family: 'Sora', sans-serif; + font-size: var(--text-lg); +} + +.detail-close { + min-height: 38px; + padding: 0.46rem 0.78rem; + color: var(--color-text-muted); + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: 999px; + font-size: var(--text-xs); + font-weight: 700; +} + +.detail-art { + width: min(100%, 220px); + margin: 0 auto; + border-radius: 12px; +} + +.detail-fields { + display: grid; + gap: 8px; +} + +.detail-field { + display: grid; + grid-template-columns: minmax(90px, 0.35fr) minmax(0, 1fr); + gap: 12px; + padding-top: 8px; + border-top: 1px solid var(--color-divider); +} + +.detail-field-label { + color: var(--color-text-faint); + font-size: var(--text-xs); + font-weight: 700; + text-transform: uppercase; +} + +.detail-field-value { + min-width: 0; + overflow-wrap: anywhere; + font-size: var(--text-sm); + text-align: right; +} + +@keyframes idle-pulse { + 0%, 100% { box-shadow: 0 0 0 0 var(--color-primary-glow), 0 12px 28px rgba(0, 0, 0, 0.22); } + 50% { box-shadow: 0 0 0 10px var(--color-primary-glow), 0 16px 32px rgba(0, 0, 0, 0.24); } +} + +@keyframes listening-ring { + 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 var(--color-primary-glow), 0 12px 28px rgba(0, 0, 0, 0.22); } + 50% { transform: scale(1.04); box-shadow: 0 0 0 18px rgba(53, 169, 255, 0), 0 16px 32px rgba(0, 0, 0, 0.24); } +} + +@keyframes processing-pulse { + 0%, 100% { opacity: 0.72; } + 50% { opacity: 1; } +} + +@keyframes result-enter { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (max-width: 640px) { + .status-summary { + grid-template-columns: auto minmax(0, 1fr); + } + + .text-button { + grid-column: 2; + justify-self: start; + padding-top: 0; + } + + .history-card { + grid-template-columns: 48px minmax(0, 1fr); + } + + .history-art { + width: 48px; + } + + .history-actions { + grid-column: 1 / -1; + } + + .history-actions .button-secondary { + flex: 1 1 120px; + } +} + +@media (max-width: 390px) { + .brand-tagline, + .section-note { + display: none; + } + + .status-message { + white-space: normal; + } + + .cta-shell { + padding: 20px 16px; + } + + .match-card-main { + grid-template-columns: 72px minmax(0, 1fr); + gap: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/web/templates/index.html b/web/templates/index.html index 8c694e9..5bca386 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -16,6 +16,7 @@ +
- DIY Shazam +
+ DIY Shazam + Audio recognition lab +
- -
-
-

Identify any song.

-

Upload WAV, MP3, M4A, AAC, OGG, FLAC, or WEBM, or record directly from your microphone.

-
- -
Checking server status…
- -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - +
+
+
+

Private by default · built to be inspected

+

Identify any song.

+

Listen from your microphone or drop in a short clip. One bounded audio pipeline keeps every backend honest.

- - -
- - +
+
+ + Checking runtime status… + +
+
-
- - +
+
+
+ + +

Tap the microphone to listen for up to 10 seconds.

+ +
+ + + +
+ +
+ + +
+

WAV, MP3, M4A, AAC, OGG, FLAC, or WEBM · up to 30 seconds

+
+ +
+
Live input
+ +
+
+
+
+
+
+
+

Your latest signal

+

Recognition result

+
+ Session-only history
+
-
- -
-

Result

-
-
+
- +
From 078f95556545280d3bf27fca37da8c3ebce316dd Mon Sep 17 00:00:00 2001 From: Shaurya Saria Date: Mon, 14 Sep 2026 17:01:04 +0530 Subject: [PATCH 2/5] feat: give audio recognition a premium interface --- web/static/style.css | 3789 ++++++++++++++++++++++++++++++++------ web/templates/index.html | 38 +- 2 files changed, 3239 insertions(+), 588 deletions(-) diff --git a/web/static/style.css b/web/static/style.css index d7411fa..959d9b3 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -467,354 +467,3057 @@ button:focus-visible { } } -#status { +/* Final cascade for the premium editorial refresh. */ +:root { + --color-bg: #f4f1ea; + --color-surface: #fbfaf7; + --color-surface-2: #ebe7df; + --color-surface-offset: #e4dfd6; + --color-divider: #d9d3ca; + --color-border: #c9c1b5; + --color-text: #171817; + --color-text-muted: #6d6a64; + --color-text-faint: #9a948a; + --color-primary: #e35c43; + --color-primary-hover: #c94b36; + --color-primary-active: #ad3d2d; + --color-primary-glow: rgba(227, 92, 67, 0.14); + --color-success: #39715e; + --color-error: #b14d42; + --color-warning: #c68a2b; + --color-wave-1: #e35c43; + --color-wave-2: rgba(227, 92, 67, 0.28); + --shadow-panel: 0 26px 70px rgba(52, 43, 33, 0.09); +} + +[data-theme="dark"] { + --color-bg: #101211; + --color-surface: #171918; + --color-surface-2: #202321; + --color-surface-offset: #252925; + --color-divider: #303530; + --color-border: #3a403a; + --color-text: #f1eee8; + --color-text-muted: #aaa79f; + --color-text-faint: #777a74; + --color-primary: #ff775d; + --color-primary-hover: #ff8b72; + --color-primary-active: #de5b45; + --color-primary-glow: rgba(255, 119, 93, 0.16); + --color-success: #72b49a; + --color-error: #e57a6c; + --color-warning: #e2ae55; + --color-wave-1: #ff775d; + --color-wave-2: rgba(255, 119, 93, 0.3); + --shadow-panel: 0 26px 70px rgba(0, 0, 0, 0.24); +} + +body { + background: var(--color-bg); +} + +main { + display: block; +} + +.app-wrap { + width: min(100%, 1160px); + padding: 28px clamp(20px, 4vw, 46px) 88px; +} + +.app-header { margin-bottom: 0; - padding: 0; - color: var(--color-text-muted); - background: color-mix(in srgb, var(--color-surface) 92%, transparent); + padding-bottom: 22px; +} + +.app-logo-icon { + width: 34px; + height: 34px; + border-radius: 10px; + background: var(--color-text); + box-shadow: none; +} + +.app-logo-icon svg { + width: 17px; + height: 17px; + fill: var(--color-bg); +} + +.app-logo-name { + font-size: 1.05rem; + letter-spacing: -0.04em; +} + +.app-logo-name span { + color: var(--color-primary); +} + +.brand-copy { + display: grid; + gap: 2px; +} + +.brand-tagline, +.environment-label, +.station-limit, +.waveform-label, +.field-help, +.section-note, +.footer-bio { + font-family: 'JetBrains Mono', ui-monospace, monospace; +} + +.brand-tagline { + color: var(--color-text-faint); + font-size: 0.6rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.header-utilities { + display: flex; + align-items: center; + gap: 18px; +} + +.environment-label { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--color-text-faint); + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.environment-dot, +.waveform-pulse { + display: inline-block; + width: 6px; + height: 6px; + background: var(--color-success); + border-radius: 50%; +} + +.theme-toggle { + width: 36px; + height: 36px; border: 1px solid var(--color-border); - border-radius: 24px; - font-family: inherit; - font-size: inherit; + border-radius: 50%; + background: transparent; + color: var(--color-text-muted); + transition: color 160ms ease-out, border-color 160ms ease-out, transform 160ms ease-out; +} + +.theme-toggle:hover { + border-color: var(--color-primary); + background: transparent; + color: var(--color-text); + transform: rotate(8deg); +} + +.hero { + padding: clamp(74px, 11vw, 138px) 0 0; +} + +.hero-copy { + display: grid; + max-width: 760px; + gap: 0; +} + +.eyebrow, +.section-label, +.upload-kicker { + color: var(--color-primary); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.66rem; + font-weight: 400; + letter-spacing: 0.14em; + line-height: 1.4; + text-transform: uppercase; +} + +.page-title { + max-width: 9ch; + margin-top: 18px; + font-size: clamp(3.8rem, 8vw, 7.4rem); + letter-spacing: -0.085em; + line-height: 0.94; +} + +.page-subtitle { + max-width: 43ch; + margin-top: 27px; + color: var(--color-text-muted); + font-size: clamp(1rem, 1.2vw, 1.16rem); line-height: 1.6; } -#recBtn.record-button { - width: 116px; - height: 116px; +.status-card { + margin-top: clamp(48px, 7vw, 76px); + overflow: visible; + border: 0; + border-top: 1px solid var(--color-divider); + border-bottom: 1px solid var(--color-divider); + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.status-summary { + min-height: 52px; + padding: 0; +} + +.status-dot, +.status-card[data-state="ready"] .status-dot, +.status-card[data-state="error"] .status-dot { + width: 7px; + height: 7px; + box-shadow: none; +} + +.status-message { + color: var(--color-text-muted); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.7rem; + letter-spacing: 0.02em; +} + +.text-button { + padding: 6px 0; + color: var(--color-text); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.65rem; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: uppercase; + transition: color 160ms ease-out; +} + +.text-button:hover { + color: var(--color-primary); + text-decoration: none; +} + +.status-details { + padding: 0 0 14px; + font-size: 0.66rem; +} + +.status-detail-row { + padding-top: 11px; +} + +.status-detail-row strong { + color: var(--color-text); + font-weight: 400; +} + +.cta-shell { + margin-top: 22px; padding: 0; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: 20px; + background: var(--color-surface); + box-shadow: var(--shadow-panel); +} + +.capture-layout { + grid-template-columns: minmax(0, 1.12fr) 1px minmax(260px, 0.88fr); + gap: 0; +} + +.capture-primary { + min-height: 388px; + align-content: center; + padding: clamp(34px, 6vw, 70px) clamp(24px, 5vw, 64px); + gap: 0; +} + +.station-topline { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.station-limit { + color: var(--color-text-faint); + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.record-button { + width: 146px; + height: 146px; + margin: 41px auto 15px; + color: var(--color-bg); + background: var(--color-text); + box-shadow: 0 0 0 10px var(--color-primary-glow), 0 20px 38px rgba(30, 27, 22, 0.16); + animation: none; + transition: transform 160ms var(--ease-out), background-color 180ms ease, box-shadow 180ms ease; +} + +.record-button::before { + inset: -17px; + border-color: var(--color-primary-glow); +} + +.record-button svg { + width: 42px; + height: 42px; +} + +.record-button:hover:not(:disabled) { background: var(--color-primary); - border: 0; - border-radius: 50%; + box-shadow: 0 0 0 10px var(--color-primary-glow), 0 22px 40px rgba(30, 27, 22, 0.2); + transform: translateY(-3px); } -#stopBtn.secondary-button { - min-height: 44px; - padding: 0.68rem 1.1rem; +.record-button:active:not(:disabled) { + transform: scale(0.97); +} + +.record-button.is-listening { + animation: listening-ring 1.15s ease-in-out infinite; +} + +.record-button.is-processing { + animation: processing-pulse 1.1s ease-in-out infinite; +} + +.record-prompt { color: var(--color-text); - background: var(--color-surface-2); + font-family: 'Sora', sans-serif; + font-size: 0.88rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.capture-hint { + min-height: 1.6em; + margin-top: 5px; + color: var(--color-text-muted); + font-size: 0.82rem; +} + +.secondary-button, +.button-secondary { + min-height: 38px; + padding: 0.5rem 0.85rem; border: 1px solid var(--color-border); - box-shadow: none; + border-radius: 8px; + background: transparent; + color: var(--color-text-muted); + font-size: 0.72rem; + font-weight: 600; + transition: color 160ms ease-out, border-color 160ms ease-out, background-color 160ms ease-out, transform 160ms ease-out; } -#stopBtn.secondary-button:disabled { +.secondary-button { + margin-top: 18px; +} + +.secondary-button:hover:not(:disabled), +.button-secondary:hover:not(:disabled) { + border-color: var(--color-text-muted); background: var(--color-surface-2); - border-color: var(--color-border); - opacity: 0.4; + color: var(--color-text); } -.match-art-placeholder, -.history-art-placeholder { - display: grid; - place-items: center; - color: var(--color-primary); - background: linear-gradient(145deg, var(--color-surface-offset), var(--color-surface-2)); - font-family: 'Sora', sans-serif; - font-size: 1.8rem; +.secondary-button:active:not(:disabled), +.button-secondary:active:not(:disabled), +.button-link:active { + transform: scale(0.97); } -.history-art-placeholder { - font-size: 1.25rem; +.capture-divider { + position: relative; + display: flex; + width: 1px; + min-height: 100%; + align-items: center; + justify-content: center; + background: var(--color-divider); } -@media (max-width: 480px) { - .app-wrap { - padding: var(--space-4) var(--space-3) var(--space-12); +.capture-divider::before, +.capture-divider::after { + display: none; +} + +.capture-divider span { + position: absolute; + padding: 8px 0; + background: var(--color-surface); + color: var(--color-text-faint); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.58rem; + letter-spacing: 0.09em; + text-transform: uppercase; + transform: rotate(-90deg); + white-space: nowrap; +} + +.upload-field { + align-content: center; + padding: 42px clamp(24px, 4vw, 48px); +} + +.upload-field label { + margin-top: 14px; + color: var(--color-text); + font-family: 'Sora', sans-serif; + font-size: clamp(1.25rem, 2.2vw, 1.65rem); + font-weight: 700; + letter-spacing: -0.055em; + line-height: 1.1; +} + +.upload-row { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + margin-top: 26px; +} + +input[type="file"] { + min-height: 64px; + padding: 9px; + overflow: hidden; + border: 1px dashed var(--color-border); + border-radius: 10px; + background: transparent; + color: var(--color-text-muted); + font-size: 0.75rem; + transition: border-color 160ms ease-out, background-color 160ms ease-out; +} + +input[type="file"]:hover { + border-color: var(--color-primary); + background: var(--color-primary-glow); +} + +input[type="file"]::file-selector-button { + margin-right: 8px; + padding: 0.48rem 0.7rem; + border: 0; + border-radius: 6px; + background: var(--color-text); + color: var(--color-bg); + font-size: 0.68rem; + font-weight: 600; +} + +#uploadBtn { + min-height: 42px; + padding: 0.65rem 0.9rem; + border: 1px solid var(--color-text); + border-radius: 8px; + background: var(--color-text); + color: var(--color-bg); + font-size: 0.74rem; + font-weight: 700; + transition: background-color 160ms ease-out, border-color 160ms ease-out, transform 160ms ease-out; +} + +#uploadBtn:hover:not(:disabled) { + border-color: var(--color-primary); + background: var(--color-primary); +} + +.field-help { + margin-top: 12px; + color: var(--color-text-faint); + font-size: 0.6rem; + line-height: 1.65; +} + +.waveform-wrap { + grid-column: 1 / -1; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 18px; + padding: 15px 22px 17px; + border-top: 1px solid var(--color-divider); +} + +.waveform-label { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--color-text-faint); + font-size: 0.6rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.waveform-pulse { + width: 5px; + height: 5px; + background: var(--color-text-faint); +} + +.waveform { + height: 32px; + justify-content: end; + gap: 3px; +} + +.wave-bar { + width: 2px; + height: 7px; + background: var(--color-wave-2); + transition: height 80ms ease-out, opacity 120ms ease-out, background-color 180ms ease; +} + +.waveform.is-active .wave-bar { + background: var(--color-wave-1); +} + +.result-section { + margin-top: clamp(76px, 10vw, 124px); +} + +.section-heading-row { + align-items: baseline; + margin-bottom: 18px; + padding-bottom: 16px; +} + +.section-heading-row > div { + gap: 7px; +} + +.section-heading { + margin-bottom: 0; + font-size: clamp(1.35rem, 2vw, 1.8rem); + letter-spacing: -0.055em; +} + +.section-note { + color: var(--color-text-faint); + font-size: 0.6rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.result-message, +.match-card { + border: 1px solid var(--color-border); + border-radius: 14px; + background: var(--color-surface); + box-shadow: none; +} + +.result-message { + border-left: 3px solid var(--color-primary); + border-radius: 0 12px 12px 0; +} + +.result-message[data-tone="error"] { + border-color: var(--color-error); +} + +.result-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: var(--color-primary-glow); + color: var(--color-primary); +} + +.result-message[data-tone="error"] .result-icon { + background: transparent; + color: var(--color-error); +} + +.result-title { + font-family: 'Sora', sans-serif; + font-size: 0.9rem; + letter-spacing: -0.025em; +} + +.result-copy, +.result-code { + font-size: 0.78rem; +} + +.match-card { + padding: clamp(18px, 4vw, 28px); +} + +.match-art, +.history-art { + border-radius: 10px; + background: var(--color-surface-2); +} + +.match-label { + color: var(--color-success); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.62rem; + font-weight: 400; + letter-spacing: 0.12em; +} + +.match-title { + font-size: clamp(1.25rem, 2vw, 1.7rem); + letter-spacing: -0.055em; +} + +.match-artist, +.match-album { + font-size: 0.8rem; +} + +.button-link { + min-height: 38px; + padding: 0.55rem 0.85rem; + border: 1px solid var(--color-text); + border-radius: 8px; + background: var(--color-text); + color: var(--color-bg); + font-size: 0.7rem; + transition: background-color 160ms ease-out, border-color 160ms ease-out, transform 160ms ease-out; +} + +.button-link:hover { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-bg); +} + +.settings-panel, +.history-section { + margin-top: clamp(64px, 9vw, 104px); +} + +.settings-panel { + gap: 14px; + padding: 25px 0 0; + border: 0; + border-top: 1px solid var(--color-divider); + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.settings-panel h2, +.history-section h2 { + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.toggle-row { + gap: 10px; +} + +.toggle-copy { + font-size: 0.78rem; + font-weight: 600; +} + +.settings-note, +.history-artist, +.history-time { + font-size: 0.68rem; +} + +.history-section { + gap: 0; +} + +.history-card { + grid-template-columns: 56px minmax(0, 1fr) auto; + gap: 14px; + padding: 16px 0; + border: 0; + border-top: 1px solid var(--color-divider); + border-radius: 0; + background: transparent; +} + +.history-card:hover { + border-color: var(--color-divider); + background: transparent; +} + +.site-footer { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 22px; + margin-top: 92px; + padding-top: 19px; +} + +.footer-bio { + max-width: 52ch; + color: var(--color-text-faint); + font-size: 0.6rem; + line-height: 1.55; + text-align: right; +} + +.detail-modal-overlay { + background: rgba(15, 16, 15, 0.7); + backdrop-filter: blur(10px); +} + +.detail-modal { + border-radius: 16px; + box-shadow: var(--shadow-panel); +} + +.detail-close { + border-radius: 8px; + background: transparent; +} + +@media (max-width: 780px) { + .environment-label { + display: none; + } + + .capture-layout { + grid-template-columns: 1fr; + } + + .capture-primary, + .upload-field, + .waveform-wrap { + grid-column: 1; + } + + .capture-divider { + width: auto; + min-height: 1px; + height: 1px; + margin: 0 24px; + } + + .capture-divider span { + padding: 0 10px; + transform: none; + } + + .upload-field { + padding-top: 38px; + padding-bottom: 42px; + } +} + +@media (max-width: 640px) { + .app-wrap { + padding-top: 20px; + } + + .hero { + padding-top: 66px; + } + + .page-title { + font-size: clamp(3.15rem, 15vw, 4.6rem); + } + + .status-summary { + grid-template-columns: auto minmax(0, 1fr); + padding: 12px 0; + } + + .text-button { + grid-column: 2; + justify-self: start; + padding: 0; + } + + .capture-primary { + min-height: 350px; + padding: 32px 20px 38px; + } + + .waveform-wrap { + grid-template-columns: 1fr; + gap: 9px; + padding: 14px 18px 17px; + } + + .waveform { + justify-content: start; + } + + .section-heading-row { + align-items: start; + } + + .section-note { + display: none; + } + + .site-footer { + display: grid; + gap: 8px; + } + + .footer-bio { + text-align: left; + } +} + +@media (max-width: 430px) { + .brand-tagline { + display: none; + } + + .status-message { + white-space: normal; + } + + .cta-shell { + border-radius: 16px; + } + + .station-topline { + align-items: start; + } + + .station-limit { + max-width: 9ch; + text-align: right; + } + + .match-card-main { + grid-template-columns: 72px minmax(0, 1fr); + gap: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + .record-button, + .record-button.is-listening, + .record-button.is-processing { + animation: none; + } +} + +#themeToggle { + padding: 0; +} + +@media (max-width: 640px) { + .hero { + padding-top: 48px; + } + + .page-title { + margin-top: 14px; + } + + .page-subtitle { + margin-top: 20px; + } + + .status-card { + margin-top: 36px; + } + + .capture-primary { + min-height: 320px; + } + + .record-button { + margin-top: 30px; + } +} + +/* Premium editorial refresh: a quiet listening station instead of a dashboard. */ +:root { + --color-bg: #f4f1ea; + --color-surface: #fbfaf7; + --color-surface-2: #ebe7df; + --color-surface-offset: #e4dfd6; + --color-divider: #d9d3ca; + --color-border: #c9c1b5; + --color-text: #171817; + --color-text-muted: #6d6a64; + --color-text-faint: #9a948a; + --color-primary: #e35c43; + --color-primary-hover: #c94b36; + --color-primary-active: #ad3d2d; + --color-primary-glow: rgba(227, 92, 67, 0.14); + --color-success: #39715e; + --color-error: #b14d42; + --color-warning: #c68a2b; + --color-wave-1: #e35c43; + --color-wave-2: rgba(227, 92, 67, 0.28); + --shadow-panel: 0 26px 70px rgba(52, 43, 33, 0.09); + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); +} + +[data-theme="dark"] { + --color-bg: #101211; + --color-surface: #171918; + --color-surface-2: #202321; + --color-surface-offset: #252925; + --color-divider: #303530; + --color-border: #3a403a; + --color-text: #f1eee8; + --color-text-muted: #aaa79f; + --color-text-faint: #777a74; + --color-primary: #ff775d; + --color-primary-hover: #ff8b72; + --color-primary-active: #de5b45; + --color-primary-glow: rgba(255, 119, 93, 0.16); + --color-success: #72b49a; + --color-error: #e57a6c; + --color-warning: #e2ae55; + --color-wave-1: #ff775d; + --color-wave-2: rgba(255, 119, 93, 0.3); + --shadow-panel: 0 26px 70px rgba(0, 0, 0, 0.24); +} + +html { + color-scheme: light; + scroll-behavior: smooth; +} + +html[data-theme="dark"] { + color-scheme: dark; +} + +body { + background-color: var(--color-bg); + background-image: none; + transition: background-color 250ms ease, color 250ms ease; +} + +button, +input { + font: inherit; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 4px; +} + +.app-wrap { + width: min(100%, 1160px); + padding: 28px clamp(20px, 4vw, 46px) 88px; +} + +.app-header { + margin-bottom: 0; + padding-bottom: 22px; + border-bottom-color: var(--color-divider); +} + +.app-logo { + gap: 11px; +} + +.app-logo-icon { + width: 34px; + height: 34px; + border-radius: 10px; + background: var(--color-text); + box-shadow: none; +} + +.app-logo-icon svg { + width: 17px; + height: 17px; + fill: var(--color-bg); +} + +.brand-copy { + display: grid; + gap: 2px; +} + +.app-logo-name { + font-size: 1.05rem; + letter-spacing: -0.04em; +} + +.app-logo-name span { + color: var(--color-primary); +} + +.brand-tagline, +.environment-label, +.station-limit, +.waveform-label, +.field-help, +.section-note, +.footer-bio { + font-family: 'JetBrains Mono', ui-monospace, monospace; +} + +.brand-tagline { + color: var(--color-text-faint); + font-size: 0.6rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.header-utilities { + display: flex; + align-items: center; + gap: 18px; +} + +.environment-label { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--color-text-faint); + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.environment-dot, +.waveform-pulse { + display: inline-block; + width: 6px; + height: 6px; + background: var(--color-success); + border-radius: 50%; +} + +.theme-toggle { + width: 36px; + height: 36px; + border: 1px solid var(--color-border); + border-radius: 50%; + background: transparent; + color: var(--color-text-muted); + transition: color 160ms ease-out, border-color 160ms ease-out, transform 160ms ease-out; +} + +.theme-toggle:hover { + border-color: var(--color-primary); + background: transparent; + color: var(--color-text); + transform: rotate(8deg); +} + +main { + display: block; +} + +.hero { + padding: clamp(74px, 11vw, 138px) 0 0; +} + +.hero-copy { + display: grid; + max-width: 760px; + gap: 0; +} + +.eyebrow, +.section-label, +.upload-kicker { + color: var(--color-primary); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.66rem; + font-weight: 400; + letter-spacing: 0.14em; + line-height: 1.4; + text-transform: uppercase; +} + +.page-title { + max-width: 9ch; + margin-top: 18px; + font-size: clamp(3.8rem, 8vw, 7.4rem); + letter-spacing: -0.085em; + line-height: 0.94; +} + +.page-subtitle { + max-width: 43ch; + margin-top: 27px; + color: var(--color-text-muted); + font-size: clamp(1rem, 1.2vw, 1.16rem); + line-height: 1.6; +} + +.status-card { + margin-top: clamp(48px, 7vw, 76px); + overflow: visible; + border: 0; + border-top: 1px solid var(--color-divider); + border-bottom: 1px solid var(--color-divider); + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.status-summary { + min-height: 52px; + padding: 0; +} + +.status-dot { + width: 7px; + height: 7px; + box-shadow: none; +} + +.status-card[data-state="ready"] .status-dot, +.status-card[data-state="error"] .status-dot { + box-shadow: none; +} + +.status-message { + color: var(--color-text-muted); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.7rem; + letter-spacing: 0.02em; +} + +.text-button { + padding: 6px 0; + color: var(--color-text); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.65rem; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: uppercase; + transition: color 160ms ease-out; +} + +.text-button:hover { + color: var(--color-primary); + text-decoration: none; +} + +.status-details { + padding: 0 0 14px; + color: var(--color-text-muted); + font-size: 0.66rem; +} + +.status-detail-row { + padding-top: 11px; + border-top-color: var(--color-divider); +} + +.status-detail-row strong { + color: var(--color-text); + font-weight: 400; +} + +.cta-shell { + margin-top: 22px; + padding: 0; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: 20px; + background: var(--color-surface); + box-shadow: var(--shadow-panel); +} + +.capture-layout { + grid-template-columns: minmax(0, 1.12fr) 1px minmax(260px, 0.88fr); + gap: 0; +} + +.capture-primary { + min-height: 388px; + align-content: center; + padding: clamp(34px, 6vw, 70px) clamp(24px, 5vw, 64px); + gap: 0; +} + +.station-topline { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.station-limit { + color: var(--color-text-faint); + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.record-button { + width: 146px; + height: 146px; + margin: 41px auto 15px; + color: var(--color-bg); + background: var(--color-text); + box-shadow: 0 0 0 10px var(--color-primary-glow), 0 20px 38px rgba(30, 27, 22, 0.16); + animation: none; + transition: transform 160ms var(--ease-out), background-color 180ms ease, box-shadow 180ms ease; +} + +.record-button::before { + inset: -17px; + border-color: var(--color-primary-glow); +} + +.record-button svg { + width: 42px; + height: 42px; +} + +.record-button:active:not(:disabled) { + transform: scale(0.97); +} + +.record-button:hover:not(:disabled) { + background: var(--color-primary); + box-shadow: 0 0 0 10px var(--color-primary-glow), 0 22px 40px rgba(30, 27, 22, 0.2); + transform: translateY(-3px); +} + +.record-button.is-listening { + animation: listening-ring 1.15s ease-in-out infinite; +} + +.record-button.is-processing { + animation: processing-pulse 1.1s ease-in-out infinite; +} + +.record-prompt { + color: var(--color-text); + font-family: 'Sora', sans-serif; + font-size: 0.88rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.capture-hint { + min-height: 1.6em; + margin-top: 5px; + color: var(--color-text-muted); + font-size: 0.82rem; +} + +.secondary-button, +.button-secondary { + min-height: 38px; + padding: 0.5rem 0.85rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: transparent; + color: var(--color-text-muted); + font-size: 0.72rem; + font-weight: 600; + transition: color 160ms ease-out, border-color 160ms ease-out, background-color 160ms ease-out, transform 160ms ease-out; +} + +.secondary-button { + margin-top: 18px; +} + +.secondary-button:hover:not(:disabled), +.button-secondary:hover:not(:disabled) { + border-color: var(--color-text-muted); + background: var(--color-surface-2); + color: var(--color-text); +} + +.secondary-button:active:not(:disabled), +.button-secondary:active:not(:disabled), +.button-link:active { + transform: scale(0.97); +} + +.capture-divider { + position: relative; + display: flex; + width: 1px; + min-height: 100%; + align-items: center; + justify-content: center; + background: var(--color-divider); +} + +.capture-divider::before, +.capture-divider::after { + display: none; +} + +.capture-divider span { + position: absolute; + padding: 8px 0; + background: var(--color-surface); + color: var(--color-text-faint); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.58rem; + letter-spacing: 0.09em; + text-transform: uppercase; + transform: rotate(-90deg); + white-space: nowrap; +} + +.upload-field { + align-content: center; + padding: 42px clamp(24px, 4vw, 48px); +} + +.upload-field label { + margin-top: 14px; + color: var(--color-text); + font-family: 'Sora', sans-serif; + font-size: clamp(1.25rem, 2.2vw, 1.65rem); + font-weight: 700; + letter-spacing: -0.055em; + line-height: 1.1; +} + +.upload-row { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 10px; + margin-top: 26px; +} + +input[type="file"] { + min-height: 64px; + padding: 9px; + overflow: hidden; + border: 1px dashed var(--color-border); + border-radius: 10px; + background: transparent; + color: var(--color-text-muted); + font-size: 0.75rem; + transition: border-color 160ms ease-out, background-color 160ms ease-out; +} + +input[type="file"]:hover { + border-color: var(--color-primary); + background: var(--color-primary-glow); +} + +input[type="file"]::file-selector-button { + margin-right: 8px; + padding: 0.48rem 0.7rem; + border: 0; + border-radius: 6px; + background: var(--color-text); + color: var(--color-bg); + font-size: 0.68rem; + font-weight: 600; + cursor: pointer; +} + +#uploadBtn { + min-height: 42px; + padding: 0.65rem 0.9rem; + border: 1px solid var(--color-text); + border-radius: 8px; + background: var(--color-text); + color: var(--color-bg); + font-size: 0.74rem; + font-weight: 700; + transition: background-color 160ms ease-out, border-color 160ms ease-out, transform 160ms ease-out; +} + +#uploadBtn:hover:not(:disabled) { + border-color: var(--color-primary); + background: var(--color-primary); +} + +#uploadBtn:active:not(:disabled) { + transform: scale(0.98); +} + +.field-help { + margin-top: 12px; + color: var(--color-text-faint); + font-size: 0.6rem; + line-height: 1.65; +} + +.waveform-wrap { + grid-column: 1 / -1; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 18px; + padding: 15px 22px 17px; + border-top: 1px solid var(--color-divider); +} + +.waveform-label { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--color-text-faint); + font-size: 0.6rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.waveform-pulse { + width: 5px; + height: 5px; + background: var(--color-text-faint); +} + +.waveform { + height: 32px; + justify-content: end; + gap: 3px; +} + +.wave-bar { + width: 2px; + height: 7px; + background: var(--color-wave-2); + transition: height 80ms ease-out, opacity 120ms ease-out, background-color 180ms ease; +} + +.waveform.is-active .wave-bar, +.waveform.is-active + .waveform-pulse { + background: var(--color-wave-1); +} + +.result-section { + margin-top: clamp(76px, 10vw, 124px); +} + +.section-heading-row { + align-items: baseline; + margin-bottom: 18px; + padding-bottom: 16px; + border-bottom: 1px solid var(--color-divider); +} + +.section-heading-row > div { + gap: 7px; +} + +.section-heading { + margin-bottom: 0; + font-size: clamp(1.35rem, 2vw, 1.8rem); + letter-spacing: -0.055em; +} + +.section-note { + color: var(--color-text-faint); + font-size: 0.6rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +#result { + min-height: 0; +} + +.result-message, +.match-card { + border: 1px solid var(--color-border); + border-radius: 14px; + background: var(--color-surface); + box-shadow: none; +} + +.result-message { + border-left: 3px solid var(--color-primary); + border-radius: 0 12px 12px 0; +} + +.result-message[data-tone="error"] { + border-color: var(--color-error); +} + +.result-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: var(--color-primary-glow); + color: var(--color-primary); +} + +.result-message[data-tone="error"] .result-icon { + background: transparent; + color: var(--color-error); +} + +.result-title { + font-family: 'Sora', sans-serif; + font-size: 0.9rem; + letter-spacing: -0.025em; +} + +.result-copy, +.result-code { + font-size: 0.78rem; +} + +.match-card { + padding: clamp(18px, 4vw, 28px); +} + +.match-art, +.history-art { + border-radius: 10px; + background: var(--color-surface-2); +} + +.match-label { + color: var(--color-success); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.62rem; + font-weight: 400; + letter-spacing: 0.12em; +} + +.match-title { + font-size: clamp(1.25rem, 2vw, 1.7rem); + letter-spacing: -0.055em; +} + +.match-artist, +.match-album { + font-size: 0.8rem; +} + +.button-link { + min-height: 38px; + padding: 0.55rem 0.85rem; + border: 1px solid var(--color-text); + border-radius: 8px; + background: var(--color-text); + color: var(--color-bg); + font-size: 0.7rem; + transition: background-color 160ms ease-out, border-color 160ms ease-out, transform 160ms ease-out; +} + +.button-link:hover { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-bg); +} + +.settings-panel, +.history-section { + margin-top: clamp(64px, 9vw, 104px); +} + +.settings-panel { + gap: 14px; + padding: 25px 0 0; + border: 0; + border-top: 1px solid var(--color-divider); + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.settings-panel h2, +.history-section h2 { + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.toggle-row { + gap: 10px; +} + +.toggle-row input { + width: 16px; + height: 16px; + accent-color: var(--color-primary); +} + +.toggle-copy { + font-size: 0.78rem; + font-weight: 600; +} + +.settings-note, +.history-artist, +.history-time { + font-size: 0.68rem; +} + +.history-section { + gap: 0; +} + +.history-card { + grid-template-columns: 56px minmax(0, 1fr) auto; + gap: 14px; + padding: 16px 0; + border: 0; + border-top: 1px solid var(--color-divider); + border-radius: 0; + background: transparent; +} + +.history-card:hover { + border-color: var(--color-divider); + background: transparent; +} + +.history-actions .button-secondary { + min-height: 34px; + padding: 0.4rem 0.65rem; + font-size: 0.64rem; +} + +.site-footer { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 22px; + margin-top: 92px; + padding-top: 19px; + border-top-color: var(--color-divider); +} + +.footer-name { + font-size: 0.72rem; +} + +.footer-name span { + color: var(--color-primary); +} + +.footer-bio { + max-width: 52ch; + color: var(--color-text-faint); + font-size: 0.6rem; + line-height: 1.55; + text-align: right; +} + +.detail-modal-overlay { + background: rgba(15, 16, 15, 0.7); + backdrop-filter: blur(10px); +} + +.detail-modal { + border-radius: 16px; + box-shadow: var(--shadow-panel); +} + +.detail-close { + border-radius: 8px; + background: transparent; + transition: color 160ms ease-out, border-color 160ms ease-out, transform 160ms ease-out; +} + +.detail-close:hover { + border-color: var(--color-primary); + background: transparent; + color: var(--color-text); +} + +@keyframes idle-pulse { + from, + to { box-shadow: 0 0 0 10px var(--color-primary-glow), 0 20px 38px rgba(30, 27, 22, 0.16); } +} + +@keyframes listening-ring { + 0%, + 100% { transform: scale(1); box-shadow: 0 0 0 10px var(--color-primary-glow), 0 20px 38px rgba(30, 27, 22, 0.16); } + 50% { transform: scale(1.025); box-shadow: 0 0 0 18px var(--color-primary-glow), 0 22px 42px rgba(30, 27, 22, 0.2); } +} + +@media (max-width: 780px) { + .environment-label { + display: none; + } + + .capture-layout { + grid-template-columns: 1fr; + } + + .capture-primary, + .upload-field, + .waveform-wrap { + grid-column: 1; + } + + .capture-divider { + width: auto; + min-height: 1px; + height: 1px; + margin: 0 24px; + } + + .capture-divider span { + padding: 0 10px; + transform: none; + } + + .upload-field { + padding-top: 38px; + padding-bottom: 42px; + } +} + +@media (max-width: 640px) { + .app-wrap { + padding-top: 20px; + } + + .hero { + padding-top: 66px; + } + + .page-title { + font-size: clamp(3.15rem, 15vw, 4.6rem); + } + + .status-summary { + grid-template-columns: auto minmax(0, 1fr); + padding: 12px 0; + } + + .text-button { + grid-column: 2; + justify-self: start; + padding: 0; + } + + .capture-primary { + min-height: 350px; + padding: 32px 20px 38px; + } + + .waveform-wrap { + grid-template-columns: 1fr; + gap: 9px; + padding: 14px 18px 17px; + } + + .waveform { + justify-content: start; + } + + .section-heading-row { + align-items: start; + } + + .section-note { + display: none; + } + + .site-footer { + display: grid; + gap: 8px; + } + + .footer-bio { + text-align: left; + } +} + +@media (max-width: 430px) { + .brand-tagline { + display: none; + } + + .cta-shell { + border-radius: 16px; + } + + .status-message { + white-space: normal; + } + + .station-topline { + align-items: start; + } + + .station-limit { + max-width: 9ch; + text-align: right; + } + + .match-card-main { + grid-template-columns: 72px minmax(0, 1fr); + gap: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + .record-button, + .record-button.is-listening, + .record-button.is-processing { + animation: none; + } +} + +#status { + margin-bottom: 0; + padding: 0; + color: var(--color-text-muted); + background: color-mix(in srgb, var(--color-surface) 92%, transparent); + border: 1px solid var(--color-border); + border-radius: 24px; + font-family: inherit; + font-size: inherit; + line-height: 1.6; +} + +#recBtn.record-button { + width: 116px; + height: 116px; + padding: 0; + background: var(--color-primary); + border: 0; + border-radius: 50%; +} + +#stopBtn.secondary-button { + min-height: 44px; + padding: 0.68rem 1.1rem; + color: var(--color-text); + background: var(--color-surface-2); + border: 1px solid var(--color-border); + box-shadow: none; +} + +#stopBtn.secondary-button:disabled { + background: var(--color-surface-2); + border-color: var(--color-border); + opacity: 0.4; +} + +.match-art-placeholder, +.history-art-placeholder { + display: grid; + place-items: center; + color: var(--color-primary); + background: linear-gradient(145deg, var(--color-surface-offset), var(--color-surface-2)); + font-family: 'Sora', sans-serif; + font-size: 1.8rem; +} + +.history-art-placeholder { + font-size: 1.25rem; +} + +@media (max-width: 480px) { + .app-wrap { + padding: var(--space-4) var(--space-3) var(--space-12); + } + + .button-row { + flex-direction: column; + } + + .button-row button { + width: 100%; + } + + .upload-row { + grid-template-columns: 1fr; + } + + .upload-row #uploadBtn { + width: 100%; + } + + .mic-cta { + width: 88px; + height: 88px; + } +} + +/* Showcase polish: keep the MVP calm, legible, and useful before a provider is configured. */ +:root { + --color-bg: #0a0d12; + --color-surface: #111720; + --color-surface-2: #172231; + --color-surface-offset: #1c2938; + --color-divider: #263444; + --color-border: #2c4054; + --color-text: #f2f6fb; + --color-text-muted: #aab8c7; + --color-text-faint: #718295; + --color-primary: #35a9ff; + --color-primary-hover: #60baff; + --color-primary-active: #1d8cda; + --color-primary-glow: rgba(53, 169, 255, 0.2); + --color-success: #45d483; + --color-warning: #f4bc5d; + --color-error: #ff7777; + --color-wave-1: #48b6ff; + --color-wave-2: rgba(72, 182, 255, 0.25); + --shadow-panel: 0 18px 44px rgba(0, 0, 0, 0.24); + --text-xl: clamp(2rem, 1.62rem + 1.8vw, 3.4rem); +} + +[data-theme="light"] { + --color-bg: #f4f8fc; + --color-surface: #fff; + --color-surface-2: #f1f6fb; + --color-surface-offset: #e7eef6; + --color-divider: #d7e1eb; + --color-border: #c4d3e1; + --color-text: #122031; + --color-text-muted: #516477; + --color-text-faint: #718497; + --color-primary: #087ec5; + --color-primary-hover: #096ca8; + --color-primary-active: #075883; + --color-primary-glow: rgba(8, 126, 197, 0.16); + --color-wave-1: #168dd0; + --color-wave-2: rgba(22, 141, 208, 0.22); + --shadow-panel: 0 18px 44px rgba(36, 70, 102, 0.13); +} + +html { + scroll-behavior: smooth; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +button:focus-visible, +input:focus-visible, +a:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 3px; +} + +.skip-link { + position: fixed; + z-index: 10000; + top: 12px; + left: 12px; + padding: 8px 12px; + color: #fff; + background: var(--color-primary-active); + border-radius: 8px; + transform: translateY(-180%); + transition: transform 180ms ease; +} + +.skip-link:focus { + transform: translateY(0); +} + +.brand-copy { + display: grid; + gap: 1px; +} + +.brand-tagline, +.section-note, +.field-help, +.waveform-label { + color: var(--color-text-faint); + font-size: var(--text-xs); +} + +.brand-tagline { + letter-spacing: 0.02em; +} + +main { + display: grid; + gap: var(--space-12); +} + +.hero { + padding-top: clamp(38px, 8vw, 80px); +} + +.hero-copy { + max-width: 720px; + gap: var(--space-3); +} + +.eyebrow, +.section-label { + color: var(--color-primary); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.page-title { + max-width: 12ch; + font-size: var(--text-xl); + letter-spacing: -0.06em; +} + +.page-subtitle { + max-width: 58ch; + font-size: var(--text-base); +} + +.status-card, +.panel { + background: color-mix(in srgb, var(--color-surface) 92%, transparent); + border: 1px solid var(--color-border); + border-radius: 24px; + box-shadow: var(--shadow-panel); +} + +.status-card { + overflow: hidden; +} + +.status-summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-height: 58px; + padding: 12px 16px; +} + +.status-dot { + width: 9px; + height: 9px; + background: var(--color-warning); + border-radius: 50%; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-warning) 14%, transparent); +} + +.status-card[data-state="ready"] .status-dot { + background: var(--color-success); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-success) 14%, transparent); +} + +.status-card[data-state="error"] .status-dot { + background: var(--color-error); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-error) 14%, transparent); +} + +.status-message { + min-width: 0; + overflow: hidden; + color: var(--color-text-muted); + font-size: var(--text-sm); + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-button { + padding: 8px 0; + color: var(--color-primary); + background: transparent; + font-size: var(--text-xs); + font-weight: 700; + white-space: nowrap; +} + +.text-button:hover { + color: var(--color-primary-hover); + text-decoration: underline; + text-underline-offset: 3px; +} + +.status-details { + display: grid; + gap: 8px; + padding: 0 16px 16px; + color: var(--color-text-muted); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: var(--text-xs); +} + +.status-details[hidden] { + display: none; +} + +.status-detail-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding-top: 8px; + border-top: 1px solid var(--color-divider); +} + +.status-detail-row strong { + color: var(--color-text); + font-weight: 500; + text-align: right; +} + +.cta-shell { + padding: clamp(24px, 5vw, 48px); +} + +.capture-layout { + display: grid; + gap: 24px; +} + +.capture-primary { + display: grid; + justify-items: center; + gap: 12px; + text-align: center; +} + +.record-button { + position: relative; + display: grid; + width: 116px; + height: 116px; + place-items: center; + color: #fff; + background: var(--color-primary); + border-radius: 50%; + box-shadow: 0 0 0 0 var(--color-primary-glow), 0 12px 28px rgba(0, 0, 0, 0.22); + transition: background 180ms ease, box-shadow 180ms ease, transform 180ms ease; + animation: idle-pulse 3s ease-in-out infinite; +} + +.record-button::before { + position: absolute; + inset: -10px; + border: 1px solid color-mix(in srgb, var(--color-primary) 28%, transparent); + border-radius: inherit; + content: ''; +} + +.record-button:hover:not(:disabled) { + background: var(--color-primary-hover); + box-shadow: 0 0 0 10px var(--color-primary-glow), 0 16px 34px rgba(0, 0, 0, 0.25); + transform: translateY(-2px); +} + +.record-button:active:not(:disabled) { + background: var(--color-primary-active); + transform: translateY(0) scale(0.98); +} + +.record-button.is-listening { + animation: listening-ring 1.15s ease-in-out infinite; +} + +.record-button.is-processing { + animation: processing-pulse 1.1s ease-in-out infinite; +} + +.record-button:disabled { + opacity: 0.72; +} + +.record-button svg { + width: 44px; + height: 44px; + fill: currentColor; +} + +.capture-hint { + max-width: 38ch; + min-height: 1.6em; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.secondary-button, +.button-secondary { + min-height: 44px; + padding: 0.68rem 1.1rem; + color: var(--color-text); + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: 999px; + font-size: var(--text-sm); + font-weight: 700; +} + +.secondary-button:hover:not(:disabled), +.button-secondary:hover:not(:disabled) { + background: var(--color-surface-offset); + border-color: var(--color-primary); +} + +.capture-divider { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + color: var(--color-text-faint); + font-size: var(--text-xs); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.capture-divider::before, +.capture-divider::after { + height: 1px; + background: var(--color-divider); + content: ''; +} + +.upload-field, +.waveform-wrap { + display: grid; + gap: 8px; +} + +.upload-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} + +input[type="file"] { + width: 100%; + min-width: 0; + padding: 0.65rem; + color: var(--color-text-muted); + background: var(--color-surface-offset); + border: 1px solid var(--color-border); + border-radius: 12px; + font-size: var(--text-sm); +} + +input[type="file"]::file-selector-button { + margin-right: 8px; + padding: 0.46rem 0.72rem; + color: #fff; + background: var(--color-primary-active); + border: 0; + border-radius: 8px; + font-size: var(--text-xs); + font-weight: 700; + cursor: pointer; +} + +#uploadBtn { + min-height: 44px; + padding: 0.68rem 1rem; + color: #fff; + background: var(--color-primary-active); + border-radius: 999px; + font-size: var(--text-sm); + font-weight: 700; + white-space: nowrap; +} + +#uploadBtn:hover:not(:disabled) { + background: var(--color-primary); +} + +.waveform-label { + text-align: center; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.waveform { + height: 46px; + align-items: center; +} + +.wave-bar { + height: 10px; + background: var(--color-wave-2); + transition: height 80ms ease, opacity 120ms ease, background 180ms ease; +} + +.waveform.is-active .wave-bar { + background: var(--color-wave-1); +} + +.result-section { + min-width: 0; +} + +.section-heading-row { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.section-heading-row > div { + display: grid; + gap: 4px; +} + +#result { + justify-items: stretch; + min-width: 0; + text-align: left; +} + +.result-message, +.match-card, +.history-card { + min-width: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 16px; +} + +.result-message { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 20px; +} + +.result-message[data-tone="error"] { + border-color: color-mix(in srgb, var(--color-error) 45%, var(--color-border)); +} + +.result-message[data-tone="success"] { + border-color: color-mix(in srgb, var(--color-success) 45%, var(--color-border)); +} + +.result-icon { + display: grid; + width: 30px; + height: 30px; + place-items: center; + color: var(--color-primary); + background: var(--color-primary-glow); + border-radius: 50%; + font-weight: 800; +} + +.result-message[data-tone="error"] .result-icon { + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 14%, transparent); +} + +.result-content { + display: grid; + min-width: 0; + gap: 4px; +} + +.result-title { + font-weight: 700; +} + +.result-copy, +.result-code { + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.result-code { + color: var(--color-text-faint); + font-family: 'JetBrains Mono', ui-monospace, monospace; +} + +.retry-button { + justify-self: start; + margin-top: 8px; +} + +.match-card { + display: grid; + gap: 20px; + padding: clamp(18px, 4vw, 28px); + animation: result-enter 220ms ease-out both; +} + +.match-card-main { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 20px; + align-items: center; +} + +.match-art, +.history-art { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + background: var(--color-surface-offset); + border-radius: 12px; +} + +.match-copy { + display: grid; + min-width: 0; + gap: 4px; +} + +.match-label { + color: var(--color-success); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.match-title { + overflow-wrap: anywhere; + font-family: 'Sora', sans-serif; + font-size: var(--text-lg); + font-weight: 700; + line-height: 1.2; +} + +.match-artist, +.match-album { + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.match-actions, +.history-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.button-link { + display: inline-flex; + min-height: 44px; + align-items: center; + padding: 0.68rem 1.1rem; + color: #fff; + background: var(--color-primary-active); + border-radius: 999px; + font-size: var(--text-sm); + font-weight: 700; + text-decoration: none; +} + +.button-link:hover { + color: #fff; + background: var(--color-primary); +} + +.settings-panel, +.history-section { + margin-top: 32px; +} + +.settings-panel { + display: grid; + gap: 12px; + padding: 20px; +} + +.settings-panel h2 { + font-family: 'Sora', sans-serif; + font-size: var(--text-lg); +} + +.toggle-row { + display: flex; + align-items: center; + gap: 12px; + color: var(--color-text); + cursor: pointer; +} + +.toggle-row input { + width: 18px; + height: 18px; + accent-color: var(--color-primary); +} + +.toggle-copy, +.history-title { + font-size: var(--text-sm); + font-weight: 700; +} + +.settings-note, +.history-artist, +.history-time { + color: var(--color-text-muted); + font-size: var(--text-xs); +} + +.history-section { + display: grid; + gap: 12px; +} + +.history-card { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 12px; + transition: background 180ms ease, border-color 180ms ease; +} + +.history-card:hover { + background: var(--color-surface-2); + border-color: var(--color-primary); +} + +.history-art { + width: 56px; + border-radius: 8px; +} + +.history-copy { + display: grid; + min-width: 0; + gap: 1px; +} + +.history-title, +.history-artist, +.history-time { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-actions .button-secondary { + min-height: 38px; + padding: 0.46rem 0.78rem; + font-size: var(--text-xs); +} + +.detail-modal-overlay { + position: fixed; + z-index: 9999; + inset: 0; + display: grid; + place-items: center; + padding: 16px; + background: rgba(4, 8, 12, 0.72); + backdrop-filter: blur(8px); +} + +.detail-modal { + display: grid; + width: min(100%, 520px); + max-height: 90vh; + gap: 16px; + overflow-y: auto; + padding: clamp(20px, 5vw, 32px); + animation: result-enter 180ms ease-out both; +} + +.detail-modal-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; +} + +.detail-modal h2 { + font-family: 'Sora', sans-serif; + font-size: var(--text-lg); +} + +.detail-close { + min-height: 38px; + padding: 0.46rem 0.78rem; + color: var(--color-text-muted); + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: 999px; + font-size: var(--text-xs); + font-weight: 700; +} + +.detail-art { + width: min(100%, 220px); + margin: 0 auto; + border-radius: 12px; +} + +.detail-fields { + display: grid; + gap: 8px; +} + +.detail-field { + display: grid; + grid-template-columns: minmax(90px, 0.35fr) minmax(0, 1fr); + gap: 12px; + padding-top: 8px; + border-top: 1px solid var(--color-divider); +} + +.detail-field-label { + color: var(--color-text-faint); + font-size: var(--text-xs); + font-weight: 700; + text-transform: uppercase; +} + +.detail-field-value { + min-width: 0; + overflow-wrap: anywhere; + font-size: var(--text-sm); + text-align: right; +} + +@keyframes idle-pulse { + 0%, 100% { box-shadow: 0 0 0 0 var(--color-primary-glow), 0 12px 28px rgba(0, 0, 0, 0.22); } + 50% { box-shadow: 0 0 0 10px var(--color-primary-glow), 0 16px 32px rgba(0, 0, 0, 0.24); } +} + +@keyframes listening-ring { + 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 var(--color-primary-glow), 0 12px 28px rgba(0, 0, 0, 0.22); } + 50% { transform: scale(1.04); box-shadow: 0 0 0 18px rgba(53, 169, 255, 0), 0 16px 32px rgba(0, 0, 0, 0.24); } +} + +@keyframes processing-pulse { + 0%, 100% { opacity: 0.72; } + 50% { opacity: 1; } +} + +@keyframes result-enter { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (max-width: 640px) { + .status-summary { + grid-template-columns: auto minmax(0, 1fr); + } + + .text-button { + grid-column: 2; + justify-self: start; + padding-top: 0; + } + + .history-card { + grid-template-columns: 48px minmax(0, 1fr); + } + + .history-art { + width: 48px; + } + + .history-actions { + grid-column: 1 / -1; + } + + .history-actions .button-secondary { + flex: 1 1 120px; + } +} + +@media (max-width: 390px) { + .brand-tagline, + .section-note { + display: none; + } + + .status-message { + white-space: normal; + } + + .cta-shell { + padding: 20px 16px; + } + + .match-card-main { + grid-template-columns: 72px minmax(0, 1fr); + gap: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +@media (max-width: 640px) { + .hero { + padding-top: 48px; } - .button-row { - flex-direction: column; + .page-title { + margin-top: 14px; } - .button-row button { - width: 100%; + .page-subtitle { + margin-top: 20px; } - .upload-row { - grid-template-columns: 1fr; + .status-card { + margin-top: 36px; } - .upload-row #uploadBtn { - width: 100%; + .capture-primary { + min-height: 320px; } - .mic-cta { - width: 88px; - height: 88px; + .record-button { + margin-top: 30px; } } -/* Showcase polish: keep the MVP calm, legible, and useful before a provider is configured. */ +/* Final authority for the premium editorial refresh. */ +#themeToggle:hover:not(:disabled) { + background: transparent; + color: var(--color-text); + transform: rotate(8deg); +} + +#themeToggle:active:not(:disabled) { + background: transparent; + transform: scale(0.97); +} + +#statusToggle:hover:not(:disabled), +#statusToggle:active:not(:disabled) { + background: transparent; + transform: none; +} + :root { - --color-bg: #0a0d12; - --color-surface: #111720; - --color-surface-2: #172231; - --color-surface-offset: #1c2938; - --color-divider: #263444; - --color-border: #2c4054; - --color-text: #f2f6fb; - --color-text-muted: #aab8c7; - --color-text-faint: #718295; - --color-primary: #35a9ff; - --color-primary-hover: #60baff; - --color-primary-active: #1d8cda; - --color-primary-glow: rgba(53, 169, 255, 0.2); - --color-success: #45d483; - --color-warning: #f4bc5d; - --color-error: #ff7777; - --color-wave-1: #48b6ff; - --color-wave-2: rgba(72, 182, 255, 0.25); - --shadow-panel: 0 18px 44px rgba(0, 0, 0, 0.24); - --text-xl: clamp(2rem, 1.62rem + 1.8vw, 3.4rem); + --color-bg: #f4f1ea; + --color-surface: #fbfaf7; + --color-surface-2: #ebe7df; + --color-surface-offset: #e4dfd6; + --color-divider: #d9d3ca; + --color-border: #c9c1b5; + --color-text: #171817; + --color-text-muted: #6d6a64; + --color-text-faint: #9a948a; + --color-primary: #e35c43; + --color-primary-hover: #c94b36; + --color-primary-active: #ad3d2d; + --color-primary-glow: rgba(227, 92, 67, 0.14); + --color-success: #39715e; + --color-error: #b14d42; + --color-warning: #c68a2b; + --color-wave-1: #e35c43; + --color-wave-2: rgba(227, 92, 67, 0.28); + --shadow-panel: 0 26px 70px rgba(52, 43, 33, 0.09); +} + +[data-theme="dark"] { + --color-bg: #101211; + --color-surface: #171918; + --color-surface-2: #202321; + --color-surface-offset: #252925; + --color-divider: #303530; + --color-border: #3a403a; + --color-text: #f1eee8; + --color-text-muted: #aaa79f; + --color-text-faint: #777a74; + --color-primary: #ff775d; + --color-primary-hover: #ff8b72; + --color-primary-active: #de5b45; + --color-primary-glow: rgba(255, 119, 93, 0.16); + --color-success: #72b49a; + --color-error: #e57a6c; + --color-warning: #e2ae55; + --color-wave-1: #ff775d; + --color-wave-2: rgba(255, 119, 93, 0.3); + --shadow-panel: 0 26px 70px rgba(0, 0, 0, 0.24); } -[data-theme="light"] { - --color-bg: #f4f8fc; - --color-surface: #fff; - --color-surface-2: #f1f6fb; - --color-surface-offset: #e7eef6; - --color-divider: #d7e1eb; - --color-border: #c4d3e1; - --color-text: #122031; - --color-text-muted: #516477; - --color-text-faint: #718497; - --color-primary: #087ec5; - --color-primary-hover: #096ca8; - --color-primary-active: #075883; - --color-primary-glow: rgba(8, 126, 197, 0.16); - --color-wave-1: #168dd0; - --color-wave-2: rgba(22, 141, 208, 0.22); - --shadow-panel: 0 18px 44px rgba(36, 70, 102, 0.13); +body { + background: var(--color-bg); } -html { - scroll-behavior: smooth; +main { + display: block; } -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; +.app-wrap { + width: min(100%, 1160px); + padding: 28px clamp(20px, 4vw, 46px) 88px; } -button:focus-visible, -input:focus-visible, -a:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 3px; +.app-header { + margin-bottom: 0; + padding-bottom: 22px; } -.skip-link { - position: fixed; - z-index: 10000; - top: 12px; - left: 12px; - padding: 8px 12px; - color: #fff; - background: var(--color-primary-active); - border-radius: 8px; - transform: translateY(-180%); - transition: transform 180ms ease; +.app-logo-icon { + width: 34px; + height: 34px; + border-radius: 10px; + background: var(--color-text); + box-shadow: none; } -.skip-link:focus { - transform: translateY(0); +.app-logo-icon svg { + width: 17px; + height: 17px; + fill: var(--color-bg); +} + +.app-logo-name { + font-size: 1.05rem; + letter-spacing: -0.04em; +} + +.app-logo-name span { + color: var(--color-primary); } .brand-copy { display: grid; - gap: 1px; + gap: 2px; } .brand-tagline, -.section-note, +.environment-label, +.station-limit, +.waveform-label, .field-help, -.waveform-label { - color: var(--color-text-faint); - font-size: var(--text-xs); +.section-note, +.footer-bio { + font-family: 'JetBrains Mono', ui-monospace, monospace; } .brand-tagline { - letter-spacing: 0.02em; + color: var(--color-text-faint); + font-size: 0.6rem; + letter-spacing: 0.1em; + text-transform: uppercase; } -main { - display: grid; - gap: var(--space-12); +.header-utilities { + display: flex; + align-items: center; + gap: 18px; +} + +.environment-label { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--color-text-faint); + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.environment-dot, +.waveform-pulse { + display: inline-block; + width: 6px; + height: 6px; + background: var(--color-success); + border-radius: 50%; +} + +.theme-toggle { + width: 36px; + height: 36px; + border: 1px solid var(--color-border); + border-radius: 50%; + background: transparent; + color: var(--color-text-muted); + transition: color 160ms ease-out, border-color 160ms ease-out, transform 160ms ease-out; +} + +.theme-toggle:hover { + border-color: var(--color-primary); + background: transparent; + color: var(--color-text); + transform: rotate(8deg); } .hero { - padding-top: clamp(38px, 8vw, 80px); + padding: clamp(74px, 11vw, 138px) 0 0; } .hero-copy { - max-width: 720px; - gap: var(--space-3); + display: grid; + max-width: 760px; + gap: 0; } .eyebrow, -.section-label { +.section-label, +.upload-kicker { color: var(--color-primary); - font-size: var(--text-xs); - font-weight: 700; - letter-spacing: 0.12em; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.66rem; + font-weight: 400; + letter-spacing: 0.14em; + line-height: 1.4; text-transform: uppercase; } .page-title { - max-width: 12ch; - font-size: var(--text-xl); - letter-spacing: -0.06em; + max-width: 9ch; + margin-top: 18px; + font-size: clamp(3.8rem, 8vw, 7.4rem); + letter-spacing: -0.085em; + line-height: 0.94; } .page-subtitle { - max-width: 58ch; - font-size: var(--text-base); -} - -.status-card, -.panel { - background: color-mix(in srgb, var(--color-surface) 92%, transparent); - border: 1px solid var(--color-border); - border-radius: 24px; - box-shadow: var(--shadow-panel); + max-width: 43ch; + margin-top: 27px; + color: var(--color-text-muted); + font-size: clamp(1rem, 1.2vw, 1.16rem); + line-height: 1.6; } .status-card { - overflow: hidden; + margin-top: clamp(48px, 7vw, 76px); + overflow: visible; + border: 0; + border-top: 1px solid var(--color-divider); + border-bottom: 1px solid var(--color-divider); + border-radius: 0; + background: transparent; + box-shadow: none; } .status-summary { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - min-height: 58px; - padding: 12px 16px; -} - -.status-dot { - width: 9px; - height: 9px; - background: var(--color-warning); - border-radius: 50%; - box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-warning) 14%, transparent); -} - -.status-card[data-state="ready"] .status-dot { - background: var(--color-success); - box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-success) 14%, transparent); + min-height: 52px; + padding: 0; } +.status-dot, +.status-card[data-state="ready"] .status-dot, .status-card[data-state="error"] .status-dot { - background: var(--color-error); - box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-error) 14%, transparent); + width: 7px; + height: 7px; + box-shadow: none; } .status-message { - min-width: 0; - overflow: hidden; color: var(--color-text-muted); - font-size: var(--text-sm); - text-overflow: ellipsis; - white-space: nowrap; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.7rem; } .text-button { - padding: 8px 0; - color: var(--color-primary); - background: transparent; - font-size: var(--text-xs); - font-weight: 700; - white-space: nowrap; + padding: 6px 0; + color: var(--color-text); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.65rem; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: uppercase; } .text-button:hover { - color: var(--color-primary-hover); - text-decoration: underline; - text-underline-offset: 3px; + color: var(--color-primary); + text-decoration: none; } .status-details { - display: grid; - gap: 8px; - padding: 0 16px 16px; - color: var(--color-text-muted); - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: var(--text-xs); -} - -.status-details[hidden] { - display: none; + padding: 0 0 14px; + font-size: 0.66rem; } .status-detail-row { - display: flex; - justify-content: space-between; - gap: 16px; - padding-top: 8px; - border-top: 1px solid var(--color-divider); + padding-top: 11px; } .status-detail-row strong { color: var(--color-text); - font-weight: 500; - text-align: right; + font-weight: 400; } .cta-shell { - padding: clamp(24px, 5vw, 48px); + margin-top: 22px; + padding: 0; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: 20px; + background: var(--color-surface); + box-shadow: var(--shadow-panel); } .capture-layout { - display: grid; - gap: 24px; + grid-template-columns: minmax(0, 1.12fr) 1px minmax(260px, 0.88fr); + gap: 0; +} + +.capture-primary { + min-height: 388px; + align-content: center; + padding: clamp(34px, 6vw, 70px) clamp(24px, 5vw, 64px); + gap: 0; +} + +.station-topline { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 16px; } -.capture-primary { - display: grid; - justify-items: center; - gap: 12px; - text-align: center; +.station-limit { + color: var(--color-text-faint); + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; } .record-button { - position: relative; - display: grid; - width: 116px; - height: 116px; - place-items: center; - color: #fff; - background: var(--color-primary); - border-radius: 50%; - box-shadow: 0 0 0 0 var(--color-primary-glow), 0 12px 28px rgba(0, 0, 0, 0.22); - transition: background 180ms ease, box-shadow 180ms ease, transform 180ms ease; - animation: idle-pulse 3s ease-in-out infinite; + width: 146px; + height: 146px; + margin: 41px auto 15px; + color: var(--color-bg); + background: var(--color-text); + box-shadow: 0 0 0 10px var(--color-primary-glow), 0 20px 38px rgba(30, 27, 22, 0.16); + animation: none; + transition: transform 160ms var(--ease-out), background-color 180ms ease, box-shadow 180ms ease; } .record-button::before { - position: absolute; - inset: -10px; - border: 1px solid color-mix(in srgb, var(--color-primary) 28%, transparent); - border-radius: inherit; - content: ''; + inset: -17px; + border-color: var(--color-primary-glow); +} + +.record-button svg { + width: 42px; + height: 42px; } .record-button:hover:not(:disabled) { - background: var(--color-primary-hover); - box-shadow: 0 0 0 10px var(--color-primary-glow), 0 16px 34px rgba(0, 0, 0, 0.25); - transform: translateY(-2px); + background: var(--color-primary); + box-shadow: 0 0 0 10px var(--color-primary-glow), 0 22px 40px rgba(30, 27, 22, 0.2); + transform: translateY(-3px); } .record-button:active:not(:disabled) { - background: var(--color-primary-active); - transform: translateY(0) scale(0.98); + transform: scale(0.97); } .record-button.is-listening { @@ -825,125 +3528,175 @@ main { animation: processing-pulse 1.1s ease-in-out infinite; } -.record-button:disabled { - opacity: 0.72; -} - -.record-button svg { - width: 44px; - height: 44px; - fill: currentColor; +.record-prompt { + color: var(--color-text); + font-family: 'Sora', sans-serif; + font-size: 0.88rem; + font-weight: 700; } .capture-hint { - max-width: 38ch; min-height: 1.6em; + margin-top: 5px; color: var(--color-text-muted); - font-size: var(--text-sm); + font-size: 0.82rem; } .secondary-button, .button-secondary { - min-height: 44px; - padding: 0.68rem 1.1rem; - color: var(--color-text); - background: var(--color-surface-2); + min-height: 38px; + padding: 0.5rem 0.85rem; border: 1px solid var(--color-border); - border-radius: 999px; - font-size: var(--text-sm); - font-weight: 700; + border-radius: 8px; + background: transparent; + color: var(--color-text-muted); + font-size: 0.72rem; + font-weight: 600; + transition: color 160ms ease-out, border-color 160ms ease-out, background-color 160ms ease-out, transform 160ms ease-out; +} + +.secondary-button { + margin-top: 18px; } .secondary-button:hover:not(:disabled), .button-secondary:hover:not(:disabled) { - background: var(--color-surface-offset); - border-color: var(--color-primary); + border-color: var(--color-text-muted); + background: var(--color-surface-2); + color: var(--color-text); } .capture-divider { - display: grid; - grid-template-columns: 1fr auto 1fr; + position: relative; + display: flex; + width: 1px; + min-height: 100%; align-items: center; - gap: 12px; - color: var(--color-text-faint); - font-size: var(--text-xs); - letter-spacing: 0.08em; - text-transform: uppercase; + justify-content: center; + background: var(--color-divider); } .capture-divider::before, .capture-divider::after { - height: 1px; - background: var(--color-divider); - content: ''; + display: none; } -.upload-field, -.waveform-wrap { - display: grid; - gap: 8px; +.capture-divider span { + position: absolute; + padding: 8px 0; + background: var(--color-surface); + color: var(--color-text-faint); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.58rem; + letter-spacing: 0.09em; + text-transform: uppercase; + transform: rotate(-90deg); + white-space: nowrap; +} + +.upload-field { + align-content: center; + padding: 42px clamp(24px, 4vw, 48px); +} + +.upload-field label { + margin-top: 14px; + color: var(--color-text); + font-family: 'Sora', sans-serif; + font-size: clamp(1.25rem, 2.2vw, 1.65rem); + font-weight: 700; + letter-spacing: -0.055em; + line-height: 1.1; } .upload-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 12px; - align-items: center; + grid-template-columns: minmax(0, 1fr); + gap: 10px; + margin-top: 26px; } input[type="file"] { - width: 100%; - min-width: 0; - padding: 0.65rem; + min-height: 64px; + padding: 9px; + overflow: hidden; + border: 1px dashed var(--color-border); + border-radius: 10px; + background: transparent; color: var(--color-text-muted); - background: var(--color-surface-offset); - border: 1px solid var(--color-border); - border-radius: 12px; - font-size: var(--text-sm); + font-size: 0.75rem; } input[type="file"]::file-selector-button { margin-right: 8px; - padding: 0.46rem 0.72rem; - color: #fff; - background: var(--color-primary-active); + padding: 0.48rem 0.7rem; border: 0; - border-radius: 8px; - font-size: var(--text-xs); - font-weight: 700; - cursor: pointer; + border-radius: 6px; + background: var(--color-text); + color: var(--color-bg); + font-size: 0.68rem; + font-weight: 600; } +.upload-row #uploadBtn, #uploadBtn { - min-height: 44px; - padding: 0.68rem 1rem; - color: #fff; - background: var(--color-primary-active); - border-radius: 999px; - font-size: var(--text-sm); + min-height: 42px; + padding: 0.65rem 0.9rem; + border: 1px solid var(--color-text); + border-radius: 8px; + background: var(--color-text); + color: var(--color-bg); + font-size: 0.74rem; font-weight: 700; - white-space: nowrap; } #uploadBtn:hover:not(:disabled) { + border-color: var(--color-primary); background: var(--color-primary); } +.field-help { + margin-top: 12px; + color: var(--color-text-faint); + font-size: 0.6rem; + line-height: 1.65; +} + +.waveform-wrap { + grid-column: 1 / -1; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 18px; + padding: 15px 22px 17px; + border-top: 1px solid var(--color-divider); +} + .waveform-label { - text-align: center; + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--color-text-faint); + font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; } +.waveform-pulse { + width: 5px; + height: 5px; + background: var(--color-text-faint); +} + .waveform { - height: 46px; - align-items: center; + height: 32px; + justify-content: end; + gap: 3px; } .wave-bar { - height: 10px; + width: 2px; + height: 7px; background: var(--color-wave-2); - transition: height 80ms ease, opacity 120ms ease, background 180ms ease; + transition: height 80ms ease-out, opacity 120ms ease-out, background-color 180ms ease; } .waveform.is-active .wave-bar { @@ -951,395 +3704,291 @@ input[type="file"]::file-selector-button { } .result-section { - min-width: 0; + margin-top: clamp(76px, 10vw, 124px); } .section-heading-row { - display: flex; - align-items: end; - justify-content: space-between; - gap: 16px; - margin-bottom: 16px; + align-items: baseline; + margin-bottom: 18px; + padding-bottom: 16px; } .section-heading-row > div { - display: grid; - gap: 4px; -} - -#result { - justify-items: stretch; - min-width: 0; - text-align: left; -} - -.result-message, -.match-card, -.history-card { - min-width: 0; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: 16px; -} - -.result-message { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 12px; - align-items: start; - padding: 20px; -} - -.result-message[data-tone="error"] { - border-color: color-mix(in srgb, var(--color-error) 45%, var(--color-border)); -} - -.result-message[data-tone="success"] { - border-color: color-mix(in srgb, var(--color-success) 45%, var(--color-border)); + gap: 7px; } -.result-icon { - display: grid; - width: 30px; - height: 30px; - place-items: center; - color: var(--color-primary); - background: var(--color-primary-glow); - border-radius: 50%; - font-weight: 800; -} - -.result-message[data-tone="error"] .result-icon { - color: var(--color-error); - background: color-mix(in srgb, var(--color-error) 14%, transparent); -} - -.result-content { - display: grid; - min-width: 0; - gap: 4px; -} - -.result-title { - font-weight: 700; -} - -.result-copy, -.result-code { - color: var(--color-text-muted); - font-size: var(--text-sm); +.section-heading { + margin-bottom: 0; + font-size: clamp(1.35rem, 2vw, 1.8rem); + letter-spacing: -0.055em; } -.result-code { +.section-note { color: var(--color-text-faint); - font-family: 'JetBrains Mono', ui-monospace, monospace; -} - -.retry-button { - justify-self: start; - margin-top: 8px; + font-size: 0.6rem; + letter-spacing: 0.08em; + text-transform: uppercase; } +.result-message, .match-card { - display: grid; - gap: 20px; - padding: clamp(18px, 4vw, 28px); - animation: result-enter 220ms ease-out both; -} - -.match-card-main { - display: grid; - grid-template-columns: 96px minmax(0, 1fr); - gap: 20px; - align-items: center; -} - -.match-art, -.history-art { - width: 100%; - aspect-ratio: 1; - object-fit: cover; - background: var(--color-surface-offset); - border-radius: 12px; + border: 1px solid var(--color-border); + border-radius: 14px; + background: var(--color-surface); + box-shadow: none; } -.match-copy { - display: grid; - min-width: 0; - gap: 4px; +.result-message { + border-left: 3px solid var(--color-primary); + border-radius: 0 12px 12px 0; +} + +.result-message[data-tone="error"] { + border-color: var(--color-error); +} + +.result-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: var(--color-primary-glow); + color: var(--color-primary); } .match-label { color: var(--color-success); - font-size: var(--text-xs); - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.62rem; + font-weight: 400; + letter-spacing: 0.12em; } .match-title { - overflow-wrap: anywhere; - font-family: 'Sora', sans-serif; - font-size: var(--text-lg); - font-weight: 700; - line-height: 1.2; + font-size: clamp(1.25rem, 2vw, 1.7rem); + letter-spacing: -0.055em; } .match-artist, .match-album { - color: var(--color-text-muted); - font-size: var(--text-sm); -} - -.match-actions, -.history-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; + font-size: 0.8rem; } .button-link { - display: inline-flex; - min-height: 44px; - align-items: center; - padding: 0.68rem 1.1rem; - color: #fff; - background: var(--color-primary-active); - border-radius: 999px; - font-size: var(--text-sm); - font-weight: 700; - text-decoration: none; + min-height: 38px; + padding: 0.55rem 0.85rem; + border: 1px solid var(--color-text); + border-radius: 8px; + background: var(--color-text); + color: var(--color-bg); + font-size: 0.7rem; } .button-link:hover { - color: #fff; + border-color: var(--color-primary); background: var(--color-primary); + color: var(--color-bg); } .settings-panel, .history-section { - margin-top: 32px; + margin-top: clamp(64px, 9vw, 104px); } .settings-panel { - display: grid; - gap: 12px; - padding: 20px; -} - -.settings-panel h2 { - font-family: 'Sora', sans-serif; - font-size: var(--text-lg); -} - -.toggle-row { - display: flex; - align-items: center; - gap: 12px; - color: var(--color-text); - cursor: pointer; + gap: 14px; + padding: 25px 0 0; + border: 0; + border-top: 1px solid var(--color-divider); + border-radius: 0; + background: transparent; + box-shadow: none; } -.toggle-row input { - width: 18px; - height: 18px; - accent-color: var(--color-primary); +.settings-panel h2, +.history-section h2 { + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; } -.toggle-copy, -.history-title { - font-size: var(--text-sm); - font-weight: 700; +.toggle-copy { + font-size: 0.78rem; + font-weight: 600; } .settings-note, .history-artist, .history-time { - color: var(--color-text-muted); - font-size: var(--text-xs); + font-size: 0.68rem; } .history-section { - display: grid; - gap: 12px; + gap: 0; } .history-card { - display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; - gap: 12px; - align-items: center; - padding: 12px; - transition: background 180ms ease, border-color 180ms ease; + gap: 14px; + padding: 16px 0; + border: 0; + border-top: 1px solid var(--color-divider); + border-radius: 0; + background: transparent; } .history-card:hover { - background: var(--color-surface-2); - border-color: var(--color-primary); -} - -.history-art { - width: 56px; - border-radius: 8px; -} - -.history-copy { - display: grid; - min-width: 0; - gap: 1px; + border-color: var(--color-divider); + background: transparent; } -.history-title, -.history-artist, -.history-time { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.site-footer { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 22px; + margin-top: 92px; + padding-top: 19px; } -.history-actions .button-secondary { - min-height: 38px; - padding: 0.46rem 0.78rem; - font-size: var(--text-xs); +.footer-bio { + max-width: 52ch; + color: var(--color-text-faint); + font-size: 0.6rem; + line-height: 1.55; + text-align: right; } .detail-modal-overlay { - position: fixed; - z-index: 9999; - inset: 0; - display: grid; - place-items: center; - padding: 16px; - background: rgba(4, 8, 12, 0.72); - backdrop-filter: blur(8px); + background: rgba(15, 16, 15, 0.7); + backdrop-filter: blur(10px); } .detail-modal { - display: grid; - width: min(100%, 520px); - max-height: 90vh; - gap: 16px; - overflow-y: auto; - padding: clamp(20px, 5vw, 32px); - animation: result-enter 180ms ease-out both; -} - -.detail-modal-header { - display: flex; - align-items: start; - justify-content: space-between; - gap: 16px; -} - -.detail-modal h2 { - font-family: 'Sora', sans-serif; - font-size: var(--text-lg); + border-radius: 16px; + box-shadow: var(--shadow-panel); } .detail-close { - min-height: 38px; - padding: 0.46rem 0.78rem; - color: var(--color-text-muted); - background: var(--color-surface-2); - border: 1px solid var(--color-border); - border-radius: 999px; - font-size: var(--text-xs); - font-weight: 700; + border-radius: 8px; + background: transparent; } -.detail-art { - width: min(100%, 220px); - margin: 0 auto; - border-radius: 12px; +@keyframes listening-ring { + 0%, + 100% { transform: scale(1); box-shadow: 0 0 0 10px var(--color-primary-glow), 0 20px 38px rgba(30, 27, 22, 0.16); } + 50% { transform: scale(1.025); box-shadow: 0 0 0 18px var(--color-primary-glow), 0 22px 42px rgba(30, 27, 22, 0.2); } } -.detail-fields { - display: grid; - gap: 8px; -} +@media (max-width: 780px) { + .environment-label { + display: none; + } -.detail-field { - display: grid; - grid-template-columns: minmax(90px, 0.35fr) minmax(0, 1fr); - gap: 12px; - padding-top: 8px; - border-top: 1px solid var(--color-divider); -} + .capture-layout { + grid-template-columns: 1fr; + } -.detail-field-label { - color: var(--color-text-faint); - font-size: var(--text-xs); - font-weight: 700; - text-transform: uppercase; -} + .capture-primary, + .upload-field, + .waveform-wrap { + grid-column: 1; + } -.detail-field-value { - min-width: 0; - overflow-wrap: anywhere; - font-size: var(--text-sm); - text-align: right; -} + .capture-divider { + width: auto; + min-height: 1px; + height: 1px; + margin: 0 24px; + } -@keyframes idle-pulse { - 0%, 100% { box-shadow: 0 0 0 0 var(--color-primary-glow), 0 12px 28px rgba(0, 0, 0, 0.22); } - 50% { box-shadow: 0 0 0 10px var(--color-primary-glow), 0 16px 32px rgba(0, 0, 0, 0.24); } -} + .capture-divider span { + padding: 0 10px; + transform: none; + } -@keyframes listening-ring { - 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 var(--color-primary-glow), 0 12px 28px rgba(0, 0, 0, 0.22); } - 50% { transform: scale(1.04); box-shadow: 0 0 0 18px rgba(53, 169, 255, 0), 0 16px 32px rgba(0, 0, 0, 0.24); } + .upload-field { + padding-top: 38px; + padding-bottom: 42px; + } } -@keyframes processing-pulse { - 0%, 100% { opacity: 0.72; } - 50% { opacity: 1; } -} +@media (max-width: 640px) { + .app-wrap { + padding-top: 20px; + } -@keyframes result-enter { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} + .hero { + padding-top: 66px; + } + + .page-title { + font-size: clamp(3.15rem, 15vw, 4.6rem); + } -@media (max-width: 640px) { .status-summary { grid-template-columns: auto minmax(0, 1fr); + padding: 12px 0; } .text-button { grid-column: 2; justify-self: start; - padding-top: 0; + padding: 0; } - .history-card { - grid-template-columns: 48px minmax(0, 1fr); + .capture-primary { + min-height: 350px; + padding: 32px 20px 38px; } - .history-art { - width: 48px; + .waveform-wrap { + grid-template-columns: 1fr; + gap: 9px; + padding: 14px 18px 17px; } - .history-actions { - grid-column: 1 / -1; + .waveform { + justify-content: start; } - .history-actions .button-secondary { - flex: 1 1 120px; + .section-heading-row { + align-items: start; } -} -@media (max-width: 390px) { - .brand-tagline, .section-note { display: none; } + .site-footer { + display: grid; + gap: 8px; + } + + .footer-bio { + text-align: left; + } +} + +@media (max-width: 430px) { + .brand-tagline { + display: none; + } + .status-message { white-space: normal; } .cta-shell { - padding: 20px 16px; + border-radius: 16px; + } + + .station-topline { + align-items: start; + } + + .station-limit { + max-width: 9ch; + text-align: right; } .match-card-main { @@ -1349,15 +3998,9 @@ input[type="file"]::file-selector-button { } @media (prefers-reduced-motion: reduce) { - html { - scroll-behavior: auto; - } - - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; + .record-button, + .record-button.is-listening, + .record-button.is-processing { + animation: none; } } diff --git a/web/templates/index.html b/web/templates/index.html index 5bca386..2f0d0a0 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1,5 +1,5 @@ - + @@ -32,7 +32,9 @@
- + +
-

Private by default · built to be inspected

-

Identify any song.

-

Listen from your microphone or drop in a short clip. One bounded audio pipeline keeps every backend honest.

+

01 · Listening room

+

Find the song behind the sound.

+

A quiet, private place to identify a moment of audio. Start live, or bring a short clip.

@@ -72,30 +75,35 @@

Identify any song.

- +
+ + 10 sec window +
-

Tap the microphone to listen for up to 10 seconds.

- +

Tap to identify

+

The microphone listens for up to 10 seconds.

+
- +

Have a clip already?

+
-

WAV, MP3, M4A, AAC, OGG, FLAC, or WEBM · up to 30 seconds

+

WAV, MP3, M4A, AAC, OGG, FLAC, or WEBM
Up to 30 seconds · 10 MiB max

-
Live input
+
Signal monitor
From 3c5407c7147a885da15934e47f85c03328a556e7 Mon Sep 17 00:00:00 2001 From: Shaurya Saria Date: Mon, 14 Sep 2026 17:59:25 +0530 Subject: [PATCH 3/5] feat: refine showcase typography and rhythm --- web/static/typography.css | 122 ++++++++++++++++++++++++++++++++++++++ web/templates/index.html | 3 +- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 web/static/typography.css diff --git a/web/static/typography.css b/web/static/typography.css new file mode 100644 index 0000000..a65496f --- /dev/null +++ b/web/static/typography.css @@ -0,0 +1,122 @@ +:root { + --font-display: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; + --font-body: 'Manrope', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, monospace; +} + +body { + font-family: var(--font-body); +} + +#themeToggle { + padding: 0; +} + +.page-title, +.section-heading, +.app-logo-name, +.upload-field label, +.record-prompt, +.footer-name, +.match-card-title, +.match-artist, +.detail-modal h2 { + font-family: var(--font-display); +} + +.brand-tagline, +.environment-label, +.eyebrow, +.section-label, +.upload-kicker, +.station-limit, +.waveform-label, +.field-help, +.section-note, +.footer-bio, +.capture-divider span, +.status-message, +.status-detail-row, +.text-button { + font-family: var(--font-mono); +} + +.hero-copy { + max-width: 900px; + padding-left: clamp(0px, 3vw, 44px); +} + +.eyebrow { + margin-left: clamp(0px, 1.4vw, 20px); +} + +.page-title { + max-width: 8.5ch; + font-size: clamp(4rem, 9vw, 8rem); + letter-spacing: -0.09em; + line-height: 0.91; +} + +.page-subtitle { + max-width: 39ch; + margin-left: clamp(22px, 9vw, 132px); + font-size: clamp(0.98rem, 1.15vw, 1.12rem); + line-height: 1.7; +} + +.status-card { + margin-left: clamp(0px, 3vw, 44px); +} + +.section-heading-row { + align-items: end; +} + +.section-heading { + max-width: 11ch; + font-size: clamp(2.2rem, 4vw, 3.7rem); + letter-spacing: -0.075em; + line-height: 0.96; +} + +.section-note { + margin-bottom: 0.42rem; +} + +.upload-field label { + letter-spacing: -0.065em; +} + +.record-prompt { + font-size: clamp(1rem, 1.3vw, 1.2rem); + letter-spacing: -0.035em; +} + +.footer-name { + font-size: 0.82rem; + letter-spacing: -0.035em; +} + +@media (max-width: 640px) { + .hero-copy { + padding-left: 0; + } + + .eyebrow, + .status-card { + margin-left: 0; + } + + .page-title { + max-width: 8ch; + font-size: clamp(3.35rem, 16vw, 5rem); + } + + .page-subtitle { + margin-left: 0; + } + + .section-heading { + font-size: clamp(2rem, 10vw, 3rem); + } +} diff --git a/web/templates/index.html b/web/templates/index.html index 2f0d0a0..12a522c 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -9,10 +9,11 @@ + From e486ce11f5d455091b216124f87eb9c145caee47 Mon Sep 17 00:00:00 2001 From: Shaurya Saria Date: Mon, 14 Sep 2026 18:24:23 +0530 Subject: [PATCH 4/5] docs: align showcase materials with current interface --- README.md | 12 ++- TODO.md | 5 +- docs/04-ui-ux-design-brief.md | 102 +++++++++--------- showcase/audio-recognition/case-study.md | 11 +- .../audio-recognition/evidence-checklist.md | 11 +- .../audio-recognition/presentation-outline.md | 6 +- .../audio-recognition/presentation-script.md | 4 +- web/static/typography.css | 88 +++++++++++---- 8 files changed, 148 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 4dbc4b6..bfdb683 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Validated audio pipeline · Multi-backend matching · Flask web UI · Terminal o ***

Audio Recognition

-

Identify songs from your microphone or an audio file with validated multi-backend matching

+

Identify songs from your microphone or an audio file with validated multi-backend matching

## Overview @@ -46,7 +46,7 @@ Supabase authentication, persistent user history, RLS-backed history, account de - Reproducible benchmark entry points are documented in [`evaluation/README.md`](evaluation/README.md), but no complete result is present in this checkout. - The committed FFT image at [`docs/screenshots/fft-output.png`](docs/screenshots/fft-output.png) is diagnostic output from `shazam_project.fft_analyze.analyze_audio`; the original capture command was not preserved in Git. Recreate it through the CLI's `python main.py` path after choosing `mic` or `file`. -- The current browser smoke check was run against `python web/app.py` on a local development port with debug disabled. It verified page load, status rendering, and the unsupported-upload error state; it did not use provider credentials or record microphone audio. +- The current browser smoke check was run against `python web/app.py` on a local development port with debug disabled. It verified page load, status rendering, the runtime-details disclosure, empty-upload recovery, theme switching, and the responsive visual hierarchy at 1280x900 and 390x844; it did not use provider credentials or record microphone audio. ## Performance @@ -78,10 +78,10 @@ flowchart LR F -->|Local hashes| J[Peak/hash index] G & H & I & J --> K[Normalized Result] K --> L[Display (CLI) / JSON (Web)] - classDef blue fill:#ffffff,stroke:#1E90FF,stroke-width:2px,color:#1E90FF; - class A,B,C,D,E,F,G,H,I,J,K,L blue; + classDef accent fill:#fbfaf7,stroke:#e35c43,stroke-width:2px,color:#171817; + class A,B,C,D,E,F,G,H,I,J,K,L accent; ``` -Theme: midnight navy / soft white / electric blue. The browser UI is served directly by Flask; there is no separate browser bundle. +Theme: warm paper / graphite / coral, with an optional dark mode. The browser UI is served directly by Flask; there is no separate browser bundle. ## Quickstart ### Windows PowerShell @@ -186,6 +186,8 @@ Supported env vars (see `shazam_project.config.load_config()`): `AUDD_API_TOKEN` ## Web UI `python web/app.py` serves `/`, `/static/*`, `/api/match`, and `/api/status` from the same origin. CLI file mode accepts WAV/PCM files. Web uploads support WAV, MP3, M4A, AAC, OGG, FLAC, and WEBM; non-WAV web uploads require FFmpeg on `PATH` and are converted before decoding. The browser also supports a microphone-first recording flow, manual stop, waveform visualization, expandable capability details, structured loading/error/no-match/match states, light/dark theme persistence, focus-aware song details, and session-only recognition history. +The current showcase presentation uses Space Grotesk for display headings, Manrope for body and interface copy, and IBM Plex Mono for runtime metadata. Responsive `clamp()` sizing, a small shared type scale, and a consistent label-to-heading-to-copy rhythm keep the editorial layout coherent from the 1280x900 desktop view to the 390x844 mobile view. The warm paper, graphite, and coral palette is implemented in `web/static/style.css`, with the typography refinements isolated in [`web/static/typography.css`](web/static/typography.css). + The durations are intentionally different by path: CLI microphone mode defaults to 8 seconds and accepts an interactive override; the RapidAPI/Shazam adapter sends at most the first 5 seconds of the normalized clip; browser recording auto-stops after 10 seconds but can be stopped manually; and the reproducible benchmark uses separate 4-second, 8-second, and 15-second microphone clips. Every input is downmixed to mono float32 samples in `[-1, 1]` and resampled to 44,100 Hz by default. Provider adapters receive temporary mono 16-bit PCM WAV files. Inputs shorter than 1 second, longer than 30 seconds, or larger than 10 MiB are rejected by default; all three limits are configurable. diff --git a/TODO.md b/TODO.md index 70de756..355c210 100644 --- a/TODO.md +++ b/TODO.md @@ -97,7 +97,7 @@ missing lawful corpus, credentials, or real benchmark execution. - [x] Mark unsupported Supabase/auth/history/RLS/account/settings claims as planned or remove them. - [x] Reconcile README content with the actual Flask source tree. - [x] Reconcile documented 8-second CLI, 5-second RapidAPI trim, and 10-second browser-recording behavior. -- [ ] Document the exact source and command for each screenshot, and add failure-state screenshots where useful. +- [ ] Document the exact source and command for each committed screenshot, and add failure-state screenshots where useful; the current 1280x900 and 390x844 visual review remains local-only. - [x] Remove unsupported platform claims and document API statuses, backend order, environment variables, and security boundaries. - [ ] Tick Main task 7 only after documentation describes shipped behavior rather than aspiration. @@ -237,7 +237,7 @@ missing lawful corpus, credentials, or real benchmark execution. - [x] Reconcile the documented 8-second CLI behavior, 5-second RapidAPI trim, and 10-second browser recording behavior. - [x] Document that the current FFT is diagnostic and not used for matching. - [x] Replace the stale README “Add CI” roadmap entry with the implemented pytest, coverage, lint, and build gates. -- [ ] Document the exact source and command used to generate each screenshot. +- [ ] Document the exact source and command used to generate each committed screenshot; the current 1280x900 and 390x844 visual review remains local-only. - [ ] Add screenshots or recordings for no-match, provider error, permission denial, rate limiting, and upload failure states. - [x] Add a concise README Limitations section covering noise, catalog coverage, language/region differences, and live/cover/remix versions. - [x] Remove unsupported “platforms tested” claims and state that cross-platform support is not independently verified. @@ -273,6 +273,7 @@ Record evidence here as work lands: | Date | Task/check | Evidence | Result | |---|---|---|---| +| 2026-09-14 | Typography consistency and visual review | Feature branch `codex/showcase-ready-20260913`; local Flask browser review at 1280x900 and 390x844; verified Space Grotesk / Manrope / IBM Plex Mono roles, shared responsive type tokens, aligned spacing, runtime details, empty-upload recovery, theme toggle, no overflow, and no console warnings/errors. | Showcase documentation synced to the current interface. Local visual captures were reviewed but no binary screenshot assets were committed; provider credentials, real recording, benchmark corpus, and live deployment remain open. | | 2026-09-13 | Showcase-ready browser polish | Feature branch `codex/showcase-ready-20260913`; local Flask browser smoke verified first viewport, theme toggle, runtime-details disclosure, empty-upload recovery, no horizontal overflow at 390px/320px, and no current console errors. | Microphone-first CTA, structured result/error cards, focus-aware details modal, safe external links, and current/roadmap documentation boundary added. Provider credentials, real recording, benchmark corpus, and live deployment remain open. | | 2026-08-17 | Current checkout audit and documentation reconciliation | Dedicated branch `codex/audio-recognition-p0-audit`; supported `.venv-pipeline` ran 193 tests; FFmpeg and fpcalc were available; local browser smoke checked page load, status rendering, and unsupported-upload handling; `/readyz`/quota/WSGI behavior is covered by repository tests. | Production/configuration and documentation checkboxes updated. No provider values, source catalog, microphone clips, benchmark results, or credentialed smoke evidence are present locally, so the real benchmark and release gates remain open. | | 2026-08-01 | Production rate limits | Added `production_rate_limits` migration through the Supabase CLI; private row-locked quota RPC, RLS with no public policies, server-only service-role access, HMAC client identifiers, fail-closed 503 handling, development fallback, trusted-proxy configuration, direct API-secret authentication, and Retry-After responses. | 93 tests passed; 68% total branch coverage; compileall and diff checks passed. Local/linked SQL execution remains unavailable: Docker is not running and the linked `shazam-project` is inactive; linked advisors returned no lints and migration listing timed out. | diff --git a/docs/04-ui-ux-design-brief.md b/docs/04-ui-ux-design-brief.md index 4ab8565..8e04389 100644 --- a/docs/04-ui-ux-design-brief.md +++ b/docs/04-ui-ux-design-brief.md @@ -1,8 +1,8 @@ # UI/UX Design Brief -**Project:** Shazam Clone -**Version:** 1.0 -**Date:** June 2026 +**Project:** DIY Shazam +**Version:** 1.1 — current implementation reference +**Date:** September 2026 --- @@ -10,7 +10,7 @@ ### Concept & Tone -The Shazam clone should feel **modern, dark, and musical** — evoking the sensation of sound and discovery. The primary action (recognizing music) should be front-and-center circle shape, immediate, and tactile. The UI borrows from the aesthetic of music apps (Spotify, Apple Music) but stays minimal enough to not distract from the single-purpose experience. +The app should feel **modern, warm, and musical** — evoking the sensation of sound and discovery without competing with the task. The primary action (recognizing music) is a front-and-center circular control that feels immediate and tactile. The visual language borrows the restraint of editorial tools and music apps while keeping the single-purpose experience clear. **Tone keywords:** Immersive · Minimal · Tactile · Fast @@ -18,59 +18,59 @@ The Shazam clone should feel **modern, dark, and musical** — evoking the sensa ## Color Palette -### Primary Dark Theme (default) +### Primary Light Theme (default) -The app defaults to **dark mode** as the primary theme, with light mode as an optional toggle. +The app defaults to a warm light theme, with dark mode available through the theme toggle. ```css /* Backgrounds */ ---color-bg: #0d0d0f; /* Near-black, deep dark */ ---color-surface: #13131a; /* Card/panel background */ ---color-surface-2: #1a1a24; /* Elevated card / modals */ ---color-surface-offset: #1f1f2e; /* Input fields, secondary panels */ ---color-divider: #2a2a3a; /* Subtle separators */ ---color-border: #2e2e42; /* Form borders, card outlines */ +--color-bg: #f4f1ea; /* Warm paper */ +--color-surface: #fbfaf7; /* Card/panel background */ +--color-surface-2: #ebe7df; /* Elevated card / modals */ +--color-surface-offset: #e4dfd6; /* Input fields, secondary panels */ +--color-divider: #d9d3ca; /* Subtle separators */ +--color-border: #c9c1b5; /* Form borders, card outlines */ /* Text */ ---color-text: #e8e8f0; /* Primary — near-white, slight blue */ ---color-text-muted: #8888a0; /* Secondary labels, metadata */ ---color-text-faint: #4a4a60; /* Placeholder, disabled */ +--color-text: #171817; /* Primary graphite */ +--color-text-muted: #6d6a64; /* Secondary labels, metadata */ +--color-text-faint: #9a948a; /* Placeholder, disabled */ -/* Accent — Electric Blue/Cyan (Shazam signature) */ ---color-primary: #1da1f2; /* Main interactive accent */ ---color-primary-hover: #0d8dd6; ---color-primary-active: #0a72ad; ---color-primary-glow: rgba(29,161,242,0.15); /* Glow on mic button */ +/* Accent — coral */ +--color-primary: #e35c43; /* Main interactive accent */ +--color-primary-hover: #c94b36; +--color-primary-active: #ad3d2d; +--color-primary-glow: rgba(227,92,67,0.14); /* Glow on mic button */ /* Result / Success */ ---color-success: #1db954; /* Spotify green — matched state */ +--color-success: #39715e; /* Matched state */ /* Error */ ---color-error: #e84040; /* Mic denied, no match */ +--color-error: #b14d42; /* Mic denied, no match */ /* Waveform colors */ ---color-wave-1: #1da1f2; /* Active waveform bar */ ---color-wave-2: rgba(29,161,242,0.3); /* Faded bars */ +--color-wave-1: #e35c43; /* Active waveform bar */ +--color-wave-2: rgba(227,92,67,0.28); /* Faded bars */ ``` -### Light Theme (toggle) +### Dark Theme (toggle) ```css ---color-bg: #f5f5f8; ---color-surface: #ffffff; ---color-surface-2: #f0f0f5; ---color-surface-offset: #e8e8f0; ---color-divider: #d8d8e4; ---color-border: #c8c8da; ---color-text: #12121a; ---color-text-muted: #5a5a72; ---color-text-faint: #9090a8; ---color-primary: #0d8dd6; +--color-bg: #101211; +--color-surface: #171918; +--color-surface-2: #202321; +--color-surface-offset: #252925; +--color-divider: #303530; +--color-border: #3a403a; +--color-text: #f1eee8; +--color-text-muted: #aaa79f; +--color-text-faint: #777a74; +--color-primary: #ff775d; ``` ### Why This Palette? -Blue-cyan is Shazam's signature color. Using deep near-black surfaces (not pure #000) prevents eye strain while maintaining drama. The electric blue accent on a near-black background creates strong contrast and a premium, music-app-native feel. Green is reserved exclusively for "match found" — users will immediately learn this visual language. +Warm paper and graphite make the default state feel calm and tactile; coral supplies the moment of action without turning the page into a neon dashboard. The dark theme preserves the same hierarchy and accent relationships. Green remains reserved for a matched state so that recognition feedback stays distinct. --- @@ -80,29 +80,31 @@ Blue-cyan is Shazam's signature color. Using deep near-black surfaces (not pure | Role | Font | Weight | Source | |------|------|--------|--------| -| **Display / Hero** | `Sora` | 700, 800 | Google Fonts | -| **Body / UI** | `Inter` | 400, 500, 600 | Google Fonts | -| **Monospace** (timestamps, metadata) | `JetBrains Mono` | 400 | Google Fonts | +| **Display / Hero** | `Space Grotesk` | 500, 600, 700 | Google Fonts | +| **Body / UI** | `Manrope` | 400, 500, 600, 700 | Google Fonts | +| **Monospace** (timestamps, metadata) | `IBM Plex Mono` | 400, 500 | Google Fonts | **Load snippet:** ```html - + ``` -**Rationale:** Sora is geometric and modern — it feels tech-forward without being cold. Inter is the best-in-class UI sans-serif for reading at small sizes. JetBrains Mono adds a technical/musical metadata feel to secondary info. +**Rationale:** Space Grotesk gives the hero and section headings a compact editorial voice. Manrope keeps supporting copy and controls readable, while IBM Plex Mono makes runtime metadata feel instrument-like and easy to scan. -### Type Scale (web app — capped at `--text-xl`) +### Type Scale (web app — responsive and shared) ```css ---text-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem); /* 12–14px: timestamps */ ---text-sm: clamp(0.875rem, 0.8rem + 0.35vw, 1rem); /* 14–16px: buttons, nav */ ---text-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem); /* 16–18px: body */ ---text-lg: clamp(1.125rem, 1rem + 0.75vw, 1.5rem); /* 18–24px: section headings */ ---text-xl: clamp(1.5rem, 1.2rem + 1.25vw, 2.25rem); /* 24–36px: page title MAX */ +--text-display: clamp(4rem, 8.6vw, 7.4rem); /* hero display */ +--text-section: clamp(2.2rem, 4vw, 3.7rem); /* result section */ +--text-body: clamp(0.98rem, 1.15vw, 1.12rem); /* body and UI copy */ +--text-support: clamp(0.82rem, 0.8rem + 0.15vw, 0.95rem); +--text-meta: 0.66rem; /* runtime metadata */ ``` +The implemented rhythm uses a shared 18px label-to-heading gap, a responsive 20–28px heading-to-copy gap, and a responsive 48–76px copy-to-status gap. The full overrides live in [`web/static/typography.css`](../web/static/typography.css), so the type system can be reviewed independently from the larger legacy stylesheet. + --- ## Iconography @@ -199,9 +201,9 @@ CSS: ``` ┌────────────────────────────────────────────┐ │ ┌──────────┐ │ -│ │ │ Blinding Lights │ ← --text-lg, Sora 700 -│ │ Album │ The Weeknd │ ← --text-base, Inter 500 -│ │ Art │ After Hours · 2019 · Pop │ ← --text-sm, text-muted +│ │ │ Blinding Lights │ ← Space Grotesk, card title +│ │ Album │ The Weeknd │ ← Manrope, supporting copy +│ │ Art │ After Hours · 2019 · Pop │ ← IBM Plex Mono, metadata │ │ 80×80px │ │ │ └──────────┘ │ │ │ diff --git a/showcase/audio-recognition/case-study.md b/showcase/audio-recognition/case-study.md index 86bb500..9b8b738 100644 --- a/showcase/audio-recognition/case-study.md +++ b/showcase/audio-recognition/case-study.md @@ -4,7 +4,7 @@ DIY Shazam is an audio-recognition project with a command-line entry point and a Flask browser application. It accepts microphone input and audio files, normalizes them through a shared pipeline, and attempts recognition through configured external providers or a local constellation-hash index. -**Current status:** The showcase-ready browser polish is being prepared on feature branch `codex/showcase-ready-20260913`. The feature branch passes 193 local pytest tests, 82% branch coverage, Ruff, Python compilation, and diff checks; the Flask page was also smoke-tested in the in-app browser at desktop and 390px/320px mobile widths with no current console errors. Remote `main` is at `86359d5` with successful CI run [33418693705](https://github.com/icecold009/Audio-Recognition/actions/runs/33418693705) for the merged baseline. No live demo URL, complete real-world benchmark, or credentialed provider result is currently available. +**Current status:** The showcase-ready browser presentation and typography refinement are complete on feature branch `codex/showcase-ready-20260913`. The feature branch passes 193 local pytest tests, 82% branch coverage, Ruff, Python compilation, and diff checks; the Flask page was smoke-tested in the in-app browser at 1280x900 desktop and 390x844 mobile with no current console errors. Remote `main` is at `86359d5` with successful CI run [33418693705](https://github.com/icecold009/Audio-Recognition/actions/runs/33418693705) for the merged baseline. No live demo URL, complete real-world benchmark, or credentialed provider result is currently available. ## Problem and audience @@ -65,6 +65,7 @@ The benchmark runner requires 30 source tracks, one 4-, 8-, and 15-second microp - Flask `web/` is the single supported browser application. - The browser supports a microphone-first CTA, upload, waveform visualization, expandable runtime details, structured match/error/retry states, light/dark theme, focus-aware song details, and optional session-only history. +- The presentation uses Space Grotesk for display headings, Manrope for body and interface copy, and IBM Plex Mono for runtime metadata, with shared responsive `clamp()` sizing and a consistent label-to-heading-to-copy rhythm. - Web uploads support WAV, MP3, M4A, AAC, OGG, FLAC, and WEBM; FFmpeg conversion is bounded by duration and output-size limits. - The current feature branch adds the browser presentation slice on top of the merged audio-pipeline, production-safety, benchmark-tooling, and documentation baseline. - CI runs Python tests on 3.10, 3.11, and 3.12, Ruff formatting/lint, branch coverage, dependency auditing, secret scanning, Render Blueprint validation, and a production-container smoke path. @@ -80,10 +81,10 @@ The benchmark runner requires 30 source tracks, one 4-, 8-, and 15-second microp ### Verified locally on the current showcase feature branch -- `.venv-pipeline\Scripts\python.exe -m coverage run --branch --source=shazam_project,web,scripts -m pytest -q` completed with `193 passed in 12.23s`; the initial non-elevated launcher failed before test collection because it could not create the configured Python process. +- `.venv-pipeline\Scripts\python.exe -m coverage run --branch --source=shazam_project,web,scripts -m pytest -q` completed with `193 passed in 9.62s`; the initial non-elevated launcher failed before test collection because it could not create the configured Python process. - The coverage gate reported `82%` branch coverage, Ruff lint and formatting passed, Python compilation passed, and `git diff --check` produced no output. - A local development HTTP smoke returned 200 for `/` and `/healthz`, exposed upload and record controls, returned non-secret `/api/status`, returned the stable `invalid_audio` response for a malformed upload, and returned `/readyz` 503 because no recognition backend is configured. -- In-app browser smoke verified the page identity, meaningful first viewport, empty-upload recovery card, runtime-details disclosure, theme toggle, no horizontal overflow at 390px and 320px, and no current console errors. Microphone permission was not requested. +- In-app browser smoke verified the page identity, meaningful 1280x900 desktop and 390x844 mobile views, consistent type hierarchy and spacing, empty-upload recovery card, runtime-details disclosure, theme toggle, no horizontal overflow, and no current console errors. Microphone permission was not requested. - `sounddevice.query_devices()` listed 33 host devices, including input and output devices. No microphone recording or provider call was made. ### Verified remotely on merged `main` @@ -95,7 +96,7 @@ The merged [PR #8](https://github.com/icecold009/Audio-Recognition/pull/8) recor - The current feature branch has not been pushed, so its UI changes have no remote CI result yet; the successful remote run applies to the merged baseline at `86359d5`. - No complete real-world corpus has been recorded. - No credentialed provider comparison, recognition accuracy, p95 latency, catalog coverage, or live deployment smoke is claimed. -- The in-app browser could not reach the elevated local listener, so no browser-engine/permission screenshot or real browser microphone capture is claimed. Source and mocked tests cover the recording fallback and cleanup paths. +- No microphone permission or real recording was performed; the current browser evidence is local visual/interaction smoke only. Source and mocked tests cover the recording fallback and cleanup paths. ## Challenges and tradeoffs @@ -112,7 +113,7 @@ The main lesson is that “it returned a song once” is not a useful quality cl 1. Assemble and document the legal 30-source corpus and 90 clips per backend. 2. Run the benchmark with reviewed credentials and publish generated results only after the completeness gate passes. 3. Add browser-engine smoke coverage for recording, upload, permission denial, unsupported `MediaRecorder`, result states, and resource cleanup. -4. Capture a clean screenshot set from the supported Flask app and attach commands/captions to each image. +4. Export and commit a reproducible screenshot set from the supported Flask app once the browser capture artifact path is available; keep commands and captions attached to each image. 5. Make the supported pipeline interpreter launch without elevation in the developer environment and run the full release gate on the current branch. 6. Verify a deployed instance separately if a live URL becomes available. diff --git a/showcase/audio-recognition/evidence-checklist.md b/showcase/audio-recognition/evidence-checklist.md index d67ddb7..4af9c08 100644 --- a/showcase/audio-recognition/evidence-checklist.md +++ b/showcase/audio-recognition/evidence-checklist.md @@ -9,19 +9,20 @@ | Repository source and documentation | Verified on feature branch `codex/showcase-ready-20260913` | Supports the architecture, UI, API contract, limits, matcher behavior, evaluation gates, and limitations. | | Existing FFT image | Verified: [`docs/screenshots/fft-output.png`](../../docs/screenshots/fft-output.png) | Technical visual only; caption it as “Diagnostic frequency spectrum; FFT is not the recognition algorithm.” | | Merged-main CI | Verified remotely at `86359d5` in [run 33418693705](https://github.com/icecold009/Audio-Recognition/actions/runs/33418693705) | Supports the current merged baseline's test, lint, coverage, dependency, secret, Render, and container gates. | -| Current feature branch UI smoke | Verified locally | In-app browser checks cover page identity, first viewport, theme toggle, runtime-details disclosure, empty-upload recovery, and 390px/320px overflow checks. The feature branch is not pushed yet. | +| Current feature branch UI smoke | Verified locally | In-app browser checks cover page identity, the 1280x900 desktop and 390x844 mobile views, theme toggle, runtime-details disclosure, empty-upload recovery, responsive spacing, and no horizontal overflow. The feature branch is not pushed yet. | | Live demo | Missing | Supply a public URL, then run a browser smoke check and label it live evidence. | -| Real product screenshots | Partially verified | Desktop and 390px/320px local captures were reviewed during this task; no screenshot files are committed yet. | +| Real product screenshots | Reviewed locally; no binary committed | Fresh 1280x900 desktop hero/station and 390x844 mobile captures were reviewed in the local browser. They are visual evidence for this task, not committed screenshot assets. | +| Typography and visual system | Verified locally | Space Grotesk display type, Manrope body/UI copy, IBM Plex Mono metadata, responsive `clamp()` sizing, and a shared label-to-heading-to-copy rhythm are implemented in [`web/static/typography.css`](../../web/static/typography.css). | | Real-world benchmark results | Missing by design | Assemble the legal corpus and run `scripts/benchmark.py`; do not infer quality from synthetic tests or CI. | | Credentialed provider smoke | Missing | Configure credentials outside Git and record a redacted, reproducible smoke result. | -| Fresh local test run | Verified with scoped launch | `.venv-pipeline\Scripts\python.exe -m coverage run --branch --source=shazam_project,web,scripts -m pytest -q` reported `193 passed in 12.23s`; source-scoped coverage reported 82% branch coverage, and the initial non-elevated launch failed before collection because the configured Python process was inaccessible. | +| Fresh local test run | Verified with scoped launch | `.venv-pipeline\Scripts\python.exe -m coverage run --branch --source=shazam_project,web,scripts -m pytest -q` reported `193 passed in 9.62s`; source-scoped coverage reported 82% branch coverage, and the initial non-elevated launch failed before collection because the configured Python process was inaccessible. | | Host audio device enumeration | Verified, capture not attempted | `sounddevice.query_devices()` listed 33 devices, including microphone inputs and speaker outputs. This does not prove a real recording or provider recognition result. | ## Screenshot plan | Suggested file | What to capture | Source/command | Caption | Status | |---|---|---|---|---| -| `screenshots/01-home-empty.png` | Flask home page with upload, record, and status controls | Start the supported app with `python web/app.py`, then capture `/` | “The single supported Flask browser entry point.” | **[NEEDS EVIDENCE]** | +| `screenshots/01-home-empty.png` | Flask home page with upload, record, and status controls | Start the supported app with `python web/app.py`, then capture `/` | “The single supported Flask browser entry point.” | Reviewed in local browser at 1280x900 and 390x844; no binary committed | | `screenshots/02-status-development.png` | Non-secret `/api/status` capability flags | `GET /api/status` in the local app | “Runtime capability status without secrets.” | **[NEEDS EVIDENCE]** | | `screenshots/03-invalid-upload.png` | Malformed or unsupported upload response | `POST /api/match` with a controlled invalid fixture | “Invalid audio is rejected with a stable public status.” | **[NEEDS EVIDENCE]** | | `screenshots/04-mock-match.png` | Successful mocked recognition response | Reproduce the CI container smoke fixture or a local deterministic mock | “The UI renders a matched response without exposing provider internals.” | **[NEEDS EVIDENCE]** | @@ -39,7 +40,7 @@ - The local backend uses spectral peaks, hash pairs, and time-offset consensus. - Merged-main remote CI recorded 190 tests per Python version, 81% branch coverage, lint/audit/secret gates, Render schema validation, and container smoke. - The benchmark tooling refuses incomplete result imports and keeps source audio/provider credentials out of the repository. -- The current browser polish is locally verified for the first viewport, theme toggle, runtime-details disclosure, empty-upload recovery, and narrow-screen overflow; microphone permission and real recording remain untested. +- The current browser polish is locally verified for the 1280x900 desktop and 390x844 mobile views, consistent typography/spacing, theme toggle, runtime-details disclosure, empty-upload recovery, and narrow-screen overflow; microphone permission and real recording remain untested. ### Must remain labeled as missing or unverified diff --git a/showcase/audio-recognition/presentation-outline.md b/showcase/audio-recognition/presentation-outline.md index 0913fca..a5ecc3b 100644 --- a/showcase/audio-recognition/presentation-outline.md +++ b/showcase/audio-recognition/presentation-outline.md @@ -1,6 +1,6 @@ # DIY Shazam — presentation outline -**Status:** Source-verified showcase draft. The repository is a locally validated prototype, not a verified live production demo. The current browser polish is on feature branch `codex/showcase-ready-20260913`; local pytest (193), 82% branch coverage, Ruff, compile, diff, and in-app browser smoke checks passed. Remote CI evidence applies to the merged baseline at `86359d5`; this feature branch is not pushed. +**Status:** Source-verified showcase draft. The repository is a locally validated prototype, not a verified live production demo. The current browser presentation and typography refinement are complete on feature branch `codex/showcase-ready-20260913`; local pytest (193), 82% branch coverage, Ruff, compile, diff, and in-app browser smoke checks passed. Remote CI evidence applies to the merged baseline at `86359d5`; this feature branch is not pushed. **Audience:** General portfolio audience @@ -8,7 +8,7 @@ **Length:** 7 slides, approximately 2 minutes spoken -**Live demo:** None supplied; local browser screenshots were reviewed during verification but are not committed as showcase assets. +**Live demo:** None supplied; fresh local browser captures at 1280x900 and 390x844 were reviewed during verification but are not committed as showcase assets. ## Slide 1 — DIY Shazam @@ -20,7 +20,7 @@ - Flask browser UI plus CLI entry point - RapidAPI/Shazam, AcoustID, AudD, and local fingerprint backends -**Recommended visual:** A real local screenshot of the Flask home screen showing the microphone-first CTA, concise runtime status, and upload alternative. Keep it labeled as local evidence; use `docs/screenshots/fft-output.png` only as a technical fallback, labeled as a diagnostic spectrum rather than a product screenshot. +**Recommended visual:** A real local screenshot of the Flask home screen showing the warm paper / graphite / coral palette, Space Grotesk display type, mono runtime metadata, microphone-first CTA, concise runtime status, and upload alternative. Keep it labeled as local evidence; use `docs/screenshots/fft-output.png` only as a technical fallback, labeled as a diagnostic spectrum rather than a product screenshot. **Speaker notes:** “This is DIY Shazam: a song-recognition project built around a practical input pipeline and several interchangeable backends. The interesting part is not just calling an API; it is making microphone input, uploaded files, validation, fallback, and safe failure states behave consistently.” diff --git a/showcase/audio-recognition/presentation-script.md b/showcase/audio-recognition/presentation-script.md index 51624d1..9bc808d 100644 --- a/showcase/audio-recognition/presentation-script.md +++ b/showcase/audio-recognition/presentation-script.md @@ -2,12 +2,14 @@ **Target length:** Approximately 2 minutes -**Status:** Evidence-backed script. Claims about merged-main CI remain separate from the current feature branch, whose browser polish is locally verified but not pushed. +**Status:** Evidence-backed script. Claims about merged-main CI remain separate from the current feature branch, whose browser presentation and typography refinement are locally verified but not pushed. This is DIY Shazam, a song-recognition project that accepts either a microphone recording or an audio file and routes it through multiple matching backends. The project is designed as a practical end-to-end workflow: input handling, normalization, provider fallback, safe errors, and evaluation all matter as much as the happy-path match. The problem is that audio is messy. A user may upload a different format, provide a clip that is too short or too long, lose microphone access, or hit a provider that is unavailable or has no result. The application therefore makes those states explicit instead of treating every failure as a zero or a server crash. +Visually, the browser now has a restrained editorial system: Space Grotesk carries the display hierarchy, Manrope keeps interface copy readable, and IBM Plex Mono marks runtime metadata. Shared responsive sizing and spacing keep the same listening-room composition coherent from desktop to mobile. + The main journey is simple. In the Flask browser UI, the user uploads WAV, MP3, M4A, AAC, OGG, FLAC, or WEBM, or records directly from the microphone. Web formats that need it go through bounded FFmpeg conversion. The shared pipeline downmixes to mono float32, resamples to the configured internal rate, enforces size and duration limits, and then passes the normalized clip to a matcher dispatcher. The dispatcher tries configured backends in order: RapidAPI/Shazam, AcoustID, AudD, and an educational local fingerprint index. The local path extracts spectral peaks, pairs them into hashes, and looks for consistent time offsets. FFT output is only a diagnostic visualization; it is not presented as the identification algorithm. Public responses stay deliberately small and use stable statuses such as `matched`, `no_match`, `not_configured`, `invalid_audio`, `rate_limited`, and `error`. diff --git a/web/static/typography.css b/web/static/typography.css index a65496f..947a058 100644 --- a/web/static/typography.css +++ b/web/static/typography.css @@ -2,10 +2,23 @@ --font-display: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; --font-body: 'Manrope', ui-sans-serif, system-ui, sans-serif; --font-mono: 'IBM Plex Mono', ui-monospace, monospace; + --text-display: clamp(4rem, 8.6vw, 7.4rem); + --text-section: clamp(2.2rem, 4vw, 3.7rem); + --text-body: clamp(0.98rem, 1.15vw, 1.12rem); + --text-support: clamp(0.82rem, 0.8rem + 0.15vw, 0.95rem); + --text-action: clamp(0.72rem, 0.68rem + 0.15vw, 0.82rem); + --text-meta: 0.66rem; + --tracking-display: -0.08em; + --tracking-meta: 0.14em; + --space-label-heading: 1.125rem; + --space-heading-copy: clamp(1.25rem, 2vw, 1.75rem); + --space-copy-status: clamp(3rem, 7vw, 4.75rem); } body { font-family: var(--font-body); + font-size: var(--text-body); + line-height: 1.6; } #themeToggle { @@ -39,48 +52,58 @@ body { .status-detail-row, .text-button { font-family: var(--font-mono); + font-size: var(--text-meta); + letter-spacing: var(--tracking-meta); + line-height: 1.4; } .hero-copy { max-width: 900px; - padding-left: clamp(0px, 3vw, 44px); + padding-left: 0; } .eyebrow { - margin-left: clamp(0px, 1.4vw, 20px); + margin-left: 0; } .page-title { - max-width: 8.5ch; - font-size: clamp(4rem, 9vw, 8rem); - letter-spacing: -0.09em; - line-height: 0.91; + max-width: 9ch; + margin-top: var(--space-label-heading); + font-size: var(--text-display); + letter-spacing: var(--tracking-display); + line-height: 0.93; } .page-subtitle { - max-width: 39ch; - margin-left: clamp(22px, 9vw, 132px); - font-size: clamp(0.98rem, 1.15vw, 1.12rem); - line-height: 1.7; + max-width: 42ch; + margin-left: clamp(12px, 3vw, 42px); + margin-top: var(--space-heading-copy); + font-size: var(--text-body); + line-height: 1.6; } .status-card { - margin-left: clamp(0px, 3vw, 44px); + margin-top: var(--space-copy-status); + margin-left: 0; } .section-heading-row { align-items: end; } +.section-heading-row > div { + gap: var(--space-label-heading); +} + .section-heading { - max-width: 11ch; - font-size: clamp(2.2rem, 4vw, 3.7rem); - letter-spacing: -0.075em; - line-height: 0.96; + max-width: 12ch; + font-size: var(--text-section); + letter-spacing: var(--tracking-display); + line-height: 0.98; } .section-note { - margin-bottom: 0.42rem; + margin-bottom: 0.48rem; } .upload-field label { @@ -88,15 +111,40 @@ body { } .record-prompt { - font-size: clamp(1rem, 1.3vw, 1.2rem); + font-size: var(--text-body); letter-spacing: -0.035em; } +.capture-hint, +.toggle-copy, +.settings-note, +.history-artist, +.history-time { + font-size: var(--text-support); + line-height: 1.5; +} + +.secondary-button, +.button-secondary, +.button-link, +#uploadBtn, +input[type='file']::file-selector-button { + font-size: var(--text-action); + line-height: 1.25; +} + .footer-name { - font-size: 0.82rem; + font-size: 0.8rem; letter-spacing: -0.035em; } +.status-message, +.status-detail-row, +.text-button { + font-size: var(--text-meta); + line-height: 1.5; +} + @media (max-width: 640px) { .hero-copy { padding-left: 0; @@ -108,8 +156,8 @@ body { } .page-title { - max-width: 8ch; - font-size: clamp(3.35rem, 16vw, 5rem); + max-width: 8.5ch; + font-size: clamp(3.35rem, 16vw, 4.8rem); } .page-subtitle { From 3143d46cbe6e68d45e8ea5dd44855a4d14abc893 Mon Sep 17 00:00:00 2001 From: Shaurya Saria Date: Mon, 14 Sep 2026 18:59:39 +0530 Subject: [PATCH 5/5] feat: refine microphone control typography --- docs/04-ui-ux-design-brief.md | 8 +++--- web/static/typography.css | 49 ++++++++++++++++++++++++++++------- web/templates/index.html | 3 ++- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/docs/04-ui-ux-design-brief.md b/docs/04-ui-ux-design-brief.md index 8e04389..b82ed58 100644 --- a/docs/04-ui-ux-design-brief.md +++ b/docs/04-ui-ux-design-brief.md @@ -96,11 +96,13 @@ Warm paper and graphite make the default state feel calm and tactile; coral supp ### Type Scale (web app — responsive and shared) ```css ---text-display: clamp(4rem, 8.6vw, 7.4rem); /* hero display */ ---text-section: clamp(2.2rem, 4vw, 3.7rem); /* result section */ +--text-display: clamp(3.5rem, 7.6vw, 6.5rem); /* hero display */ +--text-section: clamp(2rem, 3.4vw, 3.25rem); /* result section */ +--text-feature: clamp(1.15rem, 1.65vw, 1.45rem); /* card/control heading */ --text-body: clamp(0.98rem, 1.15vw, 1.12rem); /* body and UI copy */ --text-support: clamp(0.82rem, 0.8rem + 0.15vw, 0.95rem); ---text-meta: 0.66rem; /* runtime metadata */ +--text-action: clamp(0.8rem, 0.76rem + 0.1vw, 0.9rem); +--text-meta: clamp(0.7rem, 0.68rem + 0.1vw, 0.78rem); /* runtime metadata */ ``` The implemented rhythm uses a shared 18px label-to-heading gap, a responsive 20–28px heading-to-copy gap, and a responsive 48–76px copy-to-status gap. The full overrides live in [`web/static/typography.css`](../web/static/typography.css), so the type system can be reviewed independently from the larger legacy stylesheet. diff --git a/web/static/typography.css b/web/static/typography.css index 947a058..c943ff9 100644 --- a/web/static/typography.css +++ b/web/static/typography.css @@ -2,14 +2,15 @@ --font-display: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; --font-body: 'Manrope', ui-sans-serif, system-ui, sans-serif; --font-mono: 'IBM Plex Mono', ui-monospace, monospace; - --text-display: clamp(4rem, 8.6vw, 7.4rem); - --text-section: clamp(2.2rem, 4vw, 3.7rem); + --text-display: clamp(3.5rem, 7.6vw, 6.5rem); + --text-section: clamp(2rem, 3.4vw, 3.25rem); + --text-feature: clamp(1.15rem, 1.65vw, 1.45rem); --text-body: clamp(0.98rem, 1.15vw, 1.12rem); --text-support: clamp(0.82rem, 0.8rem + 0.15vw, 0.95rem); - --text-action: clamp(0.72rem, 0.68rem + 0.15vw, 0.82rem); - --text-meta: 0.66rem; + --text-action: clamp(0.8rem, 0.76rem + 0.1vw, 0.9rem); + --text-meta: clamp(0.7rem, 0.68rem + 0.1vw, 0.78rem); --tracking-display: -0.08em; - --tracking-meta: 0.14em; + --tracking-meta: 0.1em; --space-label-heading: 1.125rem; --space-heading-copy: clamp(1.25rem, 2vw, 1.75rem); --space-copy-status: clamp(3rem, 7vw, 4.75rem); @@ -31,8 +32,7 @@ body { .upload-field label, .record-prompt, .footer-name, -.match-card-title, -.match-artist, +.match-title, .detail-modal h2 { font-family: var(--font-display); } @@ -48,6 +48,7 @@ body { .section-note, .footer-bio, .capture-divider span, +.match-label, .status-message, .status-detail-row, .text-button { @@ -107,7 +108,9 @@ body { } .upload-field label { + font-size: var(--text-feature); letter-spacing: -0.065em; + line-height: 1.1; } .record-prompt { @@ -115,6 +118,27 @@ body { letter-spacing: -0.035em; } +.record-button svg { + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.match-title { + font-size: var(--text-feature); + letter-spacing: -0.055em; + line-height: 1.1; +} + +.match-artist, +.match-album { + font-family: var(--font-body); + font-size: var(--text-support); + line-height: 1.45; +} + .capture-hint, .toggle-copy, .settings-note, @@ -134,10 +158,15 @@ input[type='file']::file-selector-button { } .footer-name { - font-size: 0.8rem; + font-size: var(--text-action); letter-spacing: -0.035em; } +.detail-modal h2 { + font-size: var(--text-feature); + line-height: 1.1; +} + .status-message, .status-detail-row, .text-button { @@ -157,7 +186,7 @@ input[type='file']::file-selector-button { .page-title { max-width: 8.5ch; - font-size: clamp(3.35rem, 16vw, 4.8rem); + font-size: clamp(3.1rem, 13.4vw, 4.2rem); } .page-subtitle { @@ -165,6 +194,6 @@ input[type='file']::file-selector-button { } .section-heading { - font-size: clamp(2rem, 10vw, 3rem); + font-size: clamp(1.9rem, 8.5vw, 2.8rem); } } diff --git a/web/templates/index.html b/web/templates/index.html index 12a522c..52557c3 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -82,7 +82,8 @@

Find the song behind the sound.