feat: implement experimental doppler detection engine and UI toggle
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
certificates
|
||||||
25
components.json
Normal file
25
components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
// @ts-expect-error - Next.js dynamic config
|
||||||
|
allowedDevOrigins: ["*.trycloudflare.com", "192.168.68.99", "localhost"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
3493
package-lock.json
generated
3493
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -3,15 +3,24 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --hostname 0.0.0.0",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.4.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"shadcn": "^4.4.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
13
public/icon.svg
Normal file
13
public/icon.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#111111" />
|
||||||
|
<stop offset="100%" stop-color="#000000" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="100" fill="url(#bg)" />
|
||||||
|
<circle cx="256" cy="256" r="150" stroke="#00ff88" stroke-width="24" fill="none" opacity="0.3" />
|
||||||
|
<circle cx="256" cy="256" r="100" stroke="#00ff88" stroke-width="24" fill="none" opacity="0.6" />
|
||||||
|
<circle cx="256" cy="256" r="50" fill="#00ff88" />
|
||||||
|
<path d="M 256 256 L 350 162" stroke="#00ff88" stroke-width="24" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 706 B |
@@ -1,26 +1,130 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--font-heading: var(--font-geist-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root {
|
||||||
:root {
|
--background: oklch(1 0 0);
|
||||||
--background: #0a0a0a;
|
--foreground: oklch(0.145 0 0);
|
||||||
--foreground: #ededed;
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -13,8 +13,23 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "DirectionTone",
|
||||||
description: "Generated by create next app",
|
description: "High-frequency sound detector and locator",
|
||||||
|
manifest: "/manifest.ts",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
title: "ToneFinder",
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#000000",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
viewportFit: "cover",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,11 +38,13 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="en" className={`dark ${geistSans.variable} ${geistMono.variable}`} suppressHydrationWarning>
|
||||||
lang="en"
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
suppressHydrationWarning
|
||||||
>
|
className="antialiased bg-black text-white min-h-[100dvh] overscroll-none"
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
>
|
||||||
|
<div className="min-h-full flex flex-col">{children}</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/app/manifest.ts
Normal file
20
src/app/manifest.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { MetadataRoute } from 'next'
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: 'DirectionTone',
|
||||||
|
short_name: 'ToneFinder',
|
||||||
|
description: 'High-frequency sound detector and locator',
|
||||||
|
start_url: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#000000',
|
||||||
|
theme_color: '#000000',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icon.svg',
|
||||||
|
sizes: '192x192 512x512',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
274
src/app/page.tsx
274
src/app/page.tsx
@@ -1,65 +1,223 @@
|
|||||||
import Image from "next/image";
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { PermissionGate } from '@/components/PermissionGate';
|
||||||
|
import { DetectorGraph } from '@/components/DetectorGraph';
|
||||||
|
import { RadarFinder } from '@/components/RadarFinder';
|
||||||
|
import { SimpleDetector } from '@/components/SimpleDetector';
|
||||||
|
import { Spectrogram } from '@/components/Spectrogram';
|
||||||
|
import { useAudioAnalyzer } from '@/hooks/useAudioAnalyzer';
|
||||||
|
import { useDeviceOrientation } from '@/hooks/useDeviceOrientation';
|
||||||
|
import { useAppStore, BANDS, AppMode } from '@/store/useAppStore';
|
||||||
|
import { Activity, Compass, List, Navigation2, Waves, Settings, ShieldCheck, FlaskConical } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const audioHooks = useAudioAnalyzer(8192); // default 8192 for high freq resolution
|
||||||
|
const orientationHooks = useDeviceOrientation();
|
||||||
|
|
||||||
|
const { mode, setMode, selectedBandId, setSelectedBandId, history, clearHistory, addHistoryRecord, dopplerSpreadThreshold, setDopplerSpreadThreshold, dopplerAmpThreshold, setDopplerAmpThreshold, experimentalDoppler, setExperimentalDoppler } = useAppStore();
|
||||||
|
|
||||||
|
const handleSaveScan = () => {
|
||||||
|
// A simple "Snapshot" function to test the history logging
|
||||||
|
const data = audioHooks.getFrequencyData();
|
||||||
|
let maxAmp = 0;
|
||||||
|
let maxFreq = 0;
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
const band = BANDS.find(b => b.id === selectedBandId) || BANDS[0];
|
||||||
|
const freqPerBin = data.sampleRate / data.fftSize;
|
||||||
|
for(let i=0; i<data.binCount; i++) {
|
||||||
|
const f = i * freqPerBin;
|
||||||
|
if(f >= band.min && f <= band.max && data.data[i] > maxAmp) {
|
||||||
|
maxAmp = data.data[i];
|
||||||
|
maxFreq = f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addHistoryRecord({
|
||||||
|
id: Math.random().toString(36).substring(7),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
strongestFrequency: Math.round(maxFreq),
|
||||||
|
strongestDirection: orientationHooks.heading !== null ? Math.round(orientationHooks.heading) : null,
|
||||||
|
maxAmplitude: maxAmp
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const navItems: { id: AppMode; label: string; icon: any }[] = [
|
||||||
|
{ id: 'simple', label: 'Simple', icon: ShieldCheck },
|
||||||
|
{ id: 'detector', label: 'Adv. Det.', icon: Activity },
|
||||||
|
{ id: 'spectrogram', label: 'Spectro', icon: Waves },
|
||||||
|
{ id: 'history', label: 'Log', icon: List },
|
||||||
|
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<PermissionGate audioAnalyzerHooks={audioHooks} orientationHooks={orientationHooks}>
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<main className="flex-1 flex flex-col p-4 max-w-md mx-auto w-full pt-12 pb-24">
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
{/* Header / Band Selector */}
|
||||||
src="/next.svg"
|
{(mode !== 'history' && mode !== 'settings' && mode !== 'simple') && (
|
||||||
alt="Next.js logo"
|
<div className="mb-8 space-y-4">
|
||||||
width={100}
|
<h2 className="text-xl font-bold tracking-tight px-1">Target Band</h2>
|
||||||
height={20}
|
<div className="grid grid-cols-2 gap-2">
|
||||||
priority
|
{BANDS.map(b => (
|
||||||
/>
|
<button
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
key={b.id}
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
onClick={() => setSelectedBandId(b.id)}
|
||||||
To get started, edit the page.tsx file.
|
className={`px-4 py-3 rounded-xl text-sm font-semibold transition-all border ${selectedBandId === b.id ? 'bg-cyan-950/80 border-cyan-500/50 text-cyan-400' : 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:bg-zinc-800'}`}
|
||||||
</h1>
|
>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
{b.name}
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
</button>
|
||||||
<a
|
))}
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</div>
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
</div>
|
||||||
>
|
)}
|
||||||
Templates
|
|
||||||
</a>{" "}
|
{/* Dynamic Content */}
|
||||||
or the{" "}
|
<div className="flex-1 flex flex-col justify-center min-h-[400px]">
|
||||||
<a
|
{mode === 'simple' && (
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<SimpleDetector getFrequencyData={audioHooks.getFrequencyData} />
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
)}
|
||||||
>
|
|
||||||
Learning
|
{mode === 'detector' && (
|
||||||
</a>{" "}
|
<DetectorGraph getFrequencyData={audioHooks.getFrequencyData} />
|
||||||
center.
|
)}
|
||||||
</p>
|
|
||||||
</div>
|
{mode === 'radar' && (
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
<RadarFinder heading={orientationHooks.heading} isSimulated={orientationHooks.isSimulated} getFrequencyData={audioHooks.getFrequencyData} />
|
||||||
<a
|
)}
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
{mode === 'spectrogram' && (
|
||||||
target="_blank"
|
<Spectrogram getFrequencyData={audioHooks.getFrequencyData} />
|
||||||
rel="noopener noreferrer"
|
)}
|
||||||
>
|
|
||||||
<Image
|
{mode === 'history' && (
|
||||||
className="dark:invert"
|
<div className="space-y-4">
|
||||||
src="/vercel.svg"
|
<div className="flex items-center justify-between px-1">
|
||||||
alt="Vercel logomark"
|
<h2 className="text-xl font-bold">Scan Log</h2>
|
||||||
width={16}
|
<Button variant="ghost" size="sm" onClick={clearHistory} className="text-red-400 font-semibold hover:text-red-300 hover:bg-red-950/50">Clear</Button>
|
||||||
height={16}
|
</div>
|
||||||
/>
|
<div className="space-y-3">
|
||||||
Deploy Now
|
{history.length === 0 ? (
|
||||||
</a>
|
<p className="text-zinc-500 text-sm py-8 text-center">No scans saved.</p>
|
||||||
<a
|
) : (
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
history.map(item => (
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div key={item.id} className="bg-zinc-900 border border-zinc-800 rounded-xl p-4 flex items-center justify-between">
|
||||||
target="_blank"
|
<div>
|
||||||
rel="noopener noreferrer"
|
<div className="text-cyan-400 font-mono font-bold">{item.strongestFrequency} Hz</div>
|
||||||
>
|
<div className="text-xs text-zinc-500">{new Date(item.timestamp).toLocaleString()}</div>
|
||||||
Documentation
|
</div>
|
||||||
</a>
|
<div className="text-right">
|
||||||
|
<div className="flex items-center justify-end text-green-400 space-x-1">
|
||||||
|
<Compass className="w-4 h-4" />
|
||||||
|
<span className="font-mono">{item.strongestDirection !== null ? `${item.strongestDirection}°` : '--'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500">Level: {Math.round((item.maxAmplitude / 255) * 100)}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'settings' && (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight px-1">Settings</h2>
|
||||||
|
|
||||||
|
{/* Experimental Mode Toggle */}
|
||||||
|
<div className="bg-zinc-900/50 p-6 rounded-2xl border border-zinc-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${experimentalDoppler ? 'bg-purple-500/20' : 'bg-zinc-800'}`}>
|
||||||
|
<FlaskConical className={`w-5 h-5 ${experimentalDoppler ? 'text-purple-400' : 'text-zinc-500'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-zinc-200">Experimental Engine</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5 max-w-[200px]">Advanced Doppler detection with sub-bin interpolation & hysteresis</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setExperimentalDoppler(!experimentalDoppler)}
|
||||||
|
className={`relative w-12 h-7 rounded-full transition-colors duration-300 ${
|
||||||
|
experimentalDoppler ? 'bg-purple-500' : 'bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 left-0.5 w-6 h-6 rounded-full bg-white transition-transform duration-300 ${
|
||||||
|
experimentalDoppler ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6 bg-zinc-900/50 p-6 rounded-2xl border border-zinc-800">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-end mb-2">
|
||||||
|
<label className="text-sm font-semibold text-zinc-300">Doppler Spread Threshold</label>
|
||||||
|
<span className="text-cyan-400 font-mono text-sm">{dopplerSpreadThreshold} Hz</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-4">Lower values make Redshift/Blueshift detection MORE sensitive to slight frequency drifts.</p>
|
||||||
|
<input
|
||||||
|
type="range" min="1" max="30" step="1"
|
||||||
|
value={dopplerSpreadThreshold}
|
||||||
|
onChange={(e) => setDopplerSpreadThreshold(Number(e.target.value))}
|
||||||
|
className="w-full accent-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Global Sensitivity Slider */}
|
||||||
|
{mode !== 'history' && (
|
||||||
|
<div className="mt-8 px-6 py-4 bg-zinc-900/50 rounded-2xl border border-zinc-800">
|
||||||
|
<div className="flex justify-between items-end mb-2">
|
||||||
|
<label className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Detection Sensitivity</label>
|
||||||
|
<span className="text-cyan-400 font-mono text-sm">{Math.round(((150 - dopplerAmpThreshold) / 140) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min="10" max="150" step="1"
|
||||||
|
value={160 - dopplerAmpThreshold}
|
||||||
|
onChange={(e) => setDopplerAmpThreshold(160 - Number(e.target.value))}
|
||||||
|
className="w-full accent-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Global actions */}
|
||||||
|
{(mode !== 'history' && mode !== 'settings' && mode !== 'simple') && (
|
||||||
|
<div className="mt-8 flex justify-center">
|
||||||
|
<Button onClick={handleSaveScan} className="bg-cyan-600 hover:bg-cyan-500 text-white rounded-full px-8 py-6 font-bold">
|
||||||
|
Log Current Scan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
{/* Bottom Navigation */}
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-black border-t border-zinc-800/80 pb-safe pt-2 px-4 z-50">
|
||||||
|
<div className="max-w-md mx-auto flex justify-around">
|
||||||
|
{navItems.map(item => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = mode === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setMode(item.id)}
|
||||||
|
className={`flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-200 ${active ? 'text-cyan-400' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-6 h-6 mb-1 ${active ? 'animate-pulse' : ''}`} />
|
||||||
|
<span className="text-[10px] font-semibold tracking-wide">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PermissionGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
229
src/components/DetectorGraph.tsx
Normal file
229
src/components/DetectorGraph.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useAppStore, BANDS } from '@/store/useAppStore';
|
||||||
|
import { ChevronsUp, ChevronsDown, Activity, Minus, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
getFrequencyData: () => { data: Uint8Array; sampleRate: number; fftSize: number; binCount: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetectorGraph({ getFrequencyData }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const { selectedBandId, dopplerSpreadThreshold, dopplerAmpThreshold } = useAppStore();
|
||||||
|
const band = BANDS.find(b => b.id === selectedBandId) || BANDS[0];
|
||||||
|
|
||||||
|
const [peakFreq, setPeakFreq] = useState<number>(0);
|
||||||
|
const [peakAmp, setPeakAmp] = useState<number>(0);
|
||||||
|
const [movementState, setMovementState] = useState<string>('Searching...');
|
||||||
|
|
||||||
|
const baselineFreqRef = useRef<number | null>(null);
|
||||||
|
const baselineStartTimeRef = useRef<number | null>(null);
|
||||||
|
const candidateFreqRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let animationFrameId: number;
|
||||||
|
let lastUpdate = 0;
|
||||||
|
|
||||||
|
const draw = (timestamp: number) => {
|
||||||
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const audioData = getFrequencyData();
|
||||||
|
if (!audioData) return;
|
||||||
|
|
||||||
|
const { data, sampleRate, fftSize, binCount } = audioData;
|
||||||
|
|
||||||
|
// Setup canvas scale based on display size to avoid blur
|
||||||
|
const displayWidth = canvas.clientWidth;
|
||||||
|
const displayHeight = canvas.clientHeight;
|
||||||
|
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
|
||||||
|
canvas.width = displayWidth;
|
||||||
|
canvas.height = displayHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Draw frequency spectrum
|
||||||
|
ctx.beginPath();
|
||||||
|
// Adjust visual styling
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeStyle = 'rgba(34, 211, 238, 0.5)'; // cyan-400 with opacity
|
||||||
|
ctx.fillStyle = 'rgba(34, 211, 238, 0.1)';
|
||||||
|
|
||||||
|
const minDisplayFreq = 0;
|
||||||
|
const maxDisplayFreq = 24000;
|
||||||
|
const freqPerBin = sampleRate / fftSize;
|
||||||
|
|
||||||
|
let currentPeakAmp = 0;
|
||||||
|
let currentPeakFreq = 0;
|
||||||
|
|
||||||
|
ctx.moveTo(0, canvas.height);
|
||||||
|
|
||||||
|
for (let i = 0; i < binCount; i++) {
|
||||||
|
const freq = i * freqPerBin;
|
||||||
|
if (freq > maxDisplayFreq) break;
|
||||||
|
|
||||||
|
const val = data[i];
|
||||||
|
|
||||||
|
// Isolate logic for peak detection within selected band
|
||||||
|
if (freq >= band.min && freq <= band.max) {
|
||||||
|
if (val > currentPeakAmp) {
|
||||||
|
currentPeakAmp = val;
|
||||||
|
currentPeakFreq = freq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = (freq / maxDisplayFreq) * canvas.width;
|
||||||
|
const normalizedVal = val / 255;
|
||||||
|
const y = canvas.height - (normalizedVal * canvas.height);
|
||||||
|
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.lineTo(canvas.width, canvas.height);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw Selected Band Highlight
|
||||||
|
ctx.fillStyle = 'rgba(0, 255, 136, 0.1)';
|
||||||
|
ctx.strokeStyle = 'rgba(0, 255, 136, 0.4)';
|
||||||
|
const startX = (band.min / maxDisplayFreq) * canvas.width;
|
||||||
|
const endX = (band.max / maxDisplayFreq) * canvas.width;
|
||||||
|
ctx.fillRect(startX, 0, endX - startX, canvas.height);
|
||||||
|
|
||||||
|
// Draw peak indicator line
|
||||||
|
if (currentPeakAmp > 50) { // arbitrary noise floor
|
||||||
|
const peakX = (currentPeakFreq / maxDisplayFreq) * canvas.width;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = '#00ff88';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.moveTo(peakX, 0);
|
||||||
|
ctx.lineTo(peakX, canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
// Doppler Locking Algorithm
|
||||||
|
if (currentPeakAmp > dopplerAmpThreshold) {
|
||||||
|
if (baselineFreqRef.current === null) {
|
||||||
|
if (baselineStartTimeRef.current === null) {
|
||||||
|
baselineStartTimeRef.current = timestamp;
|
||||||
|
candidateFreqRef.current = currentPeakFreq;
|
||||||
|
} else {
|
||||||
|
if (Math.abs(currentPeakFreq - (candidateFreqRef.current || 0)) < 50) {
|
||||||
|
if (timestamp - baselineStartTimeRef.current > 3000) {
|
||||||
|
baselineFreqRef.current = candidateFreqRef.current;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baselineStartTimeRef.current = timestamp;
|
||||||
|
candidateFreqRef.current = currentPeakFreq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (Math.abs(currentPeakFreq - baselineFreqRef.current) > 100) {
|
||||||
|
baselineFreqRef.current = null;
|
||||||
|
baselineStartTimeRef.current = timestamp;
|
||||||
|
candidateFreqRef.current = currentPeakFreq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baselineFreqRef.current = null;
|
||||||
|
baselineStartTimeRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle React state updates to 5Hz to avoid UI lag
|
||||||
|
if (timestamp - lastUpdate > 200) {
|
||||||
|
setPeakAmp(currentPeakAmp);
|
||||||
|
setPeakFreq(Math.round(currentPeakFreq));
|
||||||
|
|
||||||
|
if (baselineFreqRef.current !== null) {
|
||||||
|
const diff = currentPeakFreq - baselineFreqRef.current;
|
||||||
|
|
||||||
|
if (diff >= dopplerSpreadThreshold) {
|
||||||
|
setMovementState('moving_towards');
|
||||||
|
} else if (diff <= -dopplerSpreadThreshold) {
|
||||||
|
setMovementState('moving_away');
|
||||||
|
} else {
|
||||||
|
setMovementState('stationary');
|
||||||
|
}
|
||||||
|
} else if (baselineStartTimeRef.current !== null) {
|
||||||
|
const progress = Math.min(100, Math.round(((timestamp - baselineStartTimeRef.current) / 3000) * 100));
|
||||||
|
setMovementState(`locking... ${progress}%`);
|
||||||
|
} else {
|
||||||
|
setMovementState(currentPeakAmp > dopplerAmpThreshold ? 'analyzing' : 'searching');
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdate = timestamp;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
|
return () => cancelAnimationFrame(animationFrameId);
|
||||||
|
}, [getFrequencyData, band]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div className="flex items-start justify-between px-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="text-zinc-400 text-xs font-semibold uppercase tracking-wider">Dominant Tone</div>
|
||||||
|
<div className="text-4xl font-mono font-bold text-cyan-400 leading-tight">
|
||||||
|
{peakAmp > dopplerAmpThreshold ? `${peakFreq.toLocaleString()} Hz` : '-- Hz'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Redesigned Movement Indicator */}
|
||||||
|
<div className="mt-2 h-8">
|
||||||
|
{movementState === 'moving_towards' && (
|
||||||
|
<div className="inline-flex items-center space-x-1.5 bg-blue-500/20 text-blue-400 border border-blue-500/30 px-3 py-1 rounded-full animate-pulse">
|
||||||
|
<ChevronsUp className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide">Moving Towards</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{movementState === 'moving_away' && (
|
||||||
|
<div className="inline-flex items-center space-x-1.5 bg-red-500/20 text-red-400 border border-red-500/30 px-3 py-1 rounded-full animate-pulse">
|
||||||
|
<ChevronsDown className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide">Moving Away</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{movementState === 'dispersion' && (
|
||||||
|
<div className="inline-flex items-center space-x-1.5 bg-orange-500/20 text-orange-400 border border-orange-500/30 px-3 py-1 rounded-full">
|
||||||
|
<Activity className="w-4 h-4 animate-pulse" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide">Moving</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{movementState === 'stationary' && (
|
||||||
|
<div className="inline-flex items-center space-x-1.5 bg-green-500/10 text-green-500 border border-green-500/20 px-3 py-1 rounded-full">
|
||||||
|
<Minus className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide">Stationary</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(movementState === 'searching' || movementState === 'analyzing' || movementState.startsWith('locking')) && (
|
||||||
|
<div className="inline-flex items-center space-x-1.5 bg-zinc-800/50 text-zinc-400 border border-zinc-700/50 px-3 py-1 rounded-full">
|
||||||
|
<Search className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide">{movementState}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right flex flex-col justify-start">
|
||||||
|
<div className="text-zinc-400 text-xs font-semibold uppercase tracking-wider">Level</div>
|
||||||
|
<div className="text-2xl font-mono text-white leading-tight">
|
||||||
|
{Math.round((peakAmp / 255) * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full h-48 bg-zinc-900/50 rounded-2xl border border-zinc-800 overflow-hidden shrink-0">
|
||||||
|
<canvas ref={canvasRef} className="w-full h-full" />
|
||||||
|
|
||||||
|
{/* Graph Labels */}
|
||||||
|
<div className="absolute bottom-1 left-2 text-[10px] font-mono text-zinc-500">0Hz</div>
|
||||||
|
<div className="absolute bottom-1 right-2 text-[10px] font-mono text-zinc-500">24kHz</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/PermissionGate.tsx
Normal file
111
src/components/PermissionGate.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAudioAnalyzer } from '@/hooks/useAudioAnalyzer';
|
||||||
|
import { useDeviceOrientation } from '@/hooks/useDeviceOrientation';
|
||||||
|
import { Mic, Compass, ShieldAlert } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
audioAnalyzerHooks: ReturnType<typeof useAudioAnalyzer>;
|
||||||
|
orientationHooks: ReturnType<typeof useDeviceOrientation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionGate({ children, audioAnalyzerHooks, orientationHooks }: Props) {
|
||||||
|
const [micState, setMicState] = useState<'idle' | 'loading' | 'granted' | 'denied'>('idle');
|
||||||
|
const [orientationRequested, setOrientationRequested] = useState(false);
|
||||||
|
|
||||||
|
const startMic = async () => {
|
||||||
|
setMicState('loading');
|
||||||
|
const success = await audioAnalyzerHooks.init();
|
||||||
|
if (!success) {
|
||||||
|
setMicState('denied');
|
||||||
|
} else {
|
||||||
|
setMicState('granted');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCompass = async () => {
|
||||||
|
await orientationHooks.requestPermission();
|
||||||
|
setOrientationRequested(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-update mic state if hooks update
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioAnalyzerHooks.isInitialized) {
|
||||||
|
setMicState('granted');
|
||||||
|
} else if (audioAnalyzerHooks.error) {
|
||||||
|
setMicState('denied');
|
||||||
|
}
|
||||||
|
}, [audioAnalyzerHooks.isInitialized, audioAnalyzerHooks.error]);
|
||||||
|
|
||||||
|
if (micState === 'granted' && orientationRequested) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-6 min-h-[80vh] text-center space-y-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight">DirectionTone</h1>
|
||||||
|
<p className="text-muted-foreground text-sm max-w-sm">
|
||||||
|
A professional acoustic scanner for high-frequency emissions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-sm space-y-4 bg-zinc-900/50 p-6 rounded-2xl border border-zinc-800">
|
||||||
|
|
||||||
|
{micState !== 'granted' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-3 text-left">
|
||||||
|
<div className="p-3 bg-zinc-800 rounded-full">
|
||||||
|
<Mic className="w-5 h-5 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold text-sm">Microphone Access</div>
|
||||||
|
<div className="text-xs text-zinc-400">Required to scan for audio</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={startMic}
|
||||||
|
disabled={micState === 'loading'}
|
||||||
|
className="w-full bg-cyan-600 hover:bg-cyan-500 text-white rounded-xl"
|
||||||
|
>
|
||||||
|
{micState === 'loading' ? 'Requesting...' : 'Grant Microphone'}
|
||||||
|
</Button>
|
||||||
|
{micState === 'denied' && (
|
||||||
|
<p className="text-xs text-red-400 mt-2">
|
||||||
|
{audioAnalyzerHooks.error || "Permission denied. Please enable in browser settings."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{micState === 'granted' && !orientationRequested && (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="flex items-center space-x-3 text-left">
|
||||||
|
<div className="p-3 bg-zinc-800 rounded-full">
|
||||||
|
<Compass className="w-5 h-5 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold text-sm">Motion Sensors</div>
|
||||||
|
<div className="text-xs text-zinc-400">Required for direction tracking</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={startCompass}
|
||||||
|
className="w-full bg-green-600 hover:bg-green-500 text-white rounded-xl"
|
||||||
|
>
|
||||||
|
Grant Compass Access
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start max-w-xs text-left bg-orange-950/30 text-orange-200 text-xs p-4 rounded-xl border border-orange-900/50">
|
||||||
|
<ShieldAlert className="w-4 h-4 mr-2 shrink-0 mt-0.5" />
|
||||||
|
<p>Your audio never leaves your device. All processing is done locally in your browser.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
src/components/RadarFinder.tsx
Normal file
238
src/components/RadarFinder.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useAppStore, BANDS } from '@/store/useAppStore';
|
||||||
|
import { Compass } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
heading: number | null;
|
||||||
|
isSimulated?: boolean;
|
||||||
|
getFrequencyData: () => { data: Uint8Array; sampleRate: number; fftSize: number; binCount: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadarFinder({ heading, isSimulated, getFrequencyData }: Props) {
|
||||||
|
const selectedBandId = useAppStore(state => state.selectedBandId);
|
||||||
|
const dopplerAmpThreshold = useAppStore(state => state.dopplerAmpThreshold);
|
||||||
|
const band = BANDS.find(b => b.id === selectedBandId) || BANDS[0];
|
||||||
|
|
||||||
|
// Array of 360 values, representing max amplitude seen at each degree
|
||||||
|
const [heatmap, setHeatmap] = useState<number[]>(new Array(360).fill(0));
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
// Read audio amplitude continuously and map to heading
|
||||||
|
useEffect(() => {
|
||||||
|
let animationFrameId: number;
|
||||||
|
let lastUpdate = 0;
|
||||||
|
|
||||||
|
const loop = (timestamp: number) => {
|
||||||
|
animationFrameId = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
if (heading === null) return;
|
||||||
|
|
||||||
|
const audioData = getFrequencyData();
|
||||||
|
if (!audioData) return;
|
||||||
|
|
||||||
|
const { data, sampleRate, fftSize, binCount } = audioData;
|
||||||
|
const freqPerBin = sampleRate / fftSize;
|
||||||
|
|
||||||
|
let currentPeakAmp = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < binCount; i++) {
|
||||||
|
const freq = i * freqPerBin;
|
||||||
|
if (freq >= band.min && freq <= band.max) {
|
||||||
|
if (data[i] > currentPeakAmp) {
|
||||||
|
currentPeakAmp = data[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smoothly update the heatmap array
|
||||||
|
const hStr = Math.round(heading) % 360;
|
||||||
|
// We only update state every so often to avoid excessive re-renders,
|
||||||
|
// but we maintain a local ref copy for fast canvas drawing.
|
||||||
|
|
||||||
|
setHeatmap(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
// Decay values very slowly so the heatmap persists for sweeping comparisons
|
||||||
|
for (let i = 0; i < 360; i++) {
|
||||||
|
next[i] = Math.max(0, next[i] * 0.999);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current heading with strongest value (moving average / peak hold)
|
||||||
|
next[hStr] = Math.max(next[hStr], currentPeakAmp);
|
||||||
|
|
||||||
|
// Smudge adjacent angles for visual smoothness
|
||||||
|
for (let offset = 1; offset <= 5; offset++) {
|
||||||
|
const idxPlus = (hStr + offset) % 360;
|
||||||
|
const idxMinus = (hStr - offset + 360) % 360;
|
||||||
|
next[idxPlus] = Math.max(next[idxPlus], currentPeakAmp * (1 - offset * 0.15));
|
||||||
|
next[idxMinus] = Math.max(next[idxMinus], currentPeakAmp * (1 - offset * 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(animationFrameId);
|
||||||
|
}, [heading, getFrequencyData, band]);
|
||||||
|
|
||||||
|
// Draw Radar
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const displayWidth = canvas.clientWidth;
|
||||||
|
const displayHeight = canvas.clientHeight;
|
||||||
|
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
|
||||||
|
canvas.width = displayWidth;
|
||||||
|
canvas.height = displayHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const centerX = canvas.width / 2;
|
||||||
|
const centerY = canvas.height / 2;
|
||||||
|
const radius = Math.min(centerX, centerY) - 15;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
// Rotate the entire radar so "Forward" (phone's top) is always pointing UP on the screen
|
||||||
|
if (heading !== null) {
|
||||||
|
ctx.translate(centerX, centerY);
|
||||||
|
ctx.rotate(-heading * (Math.PI / 180));
|
||||||
|
ctx.translate(-centerX, -centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw background rings
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY, (radius / 4) * i, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Cardinal Directions (N, S, E, W) inside the rotating canvas
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('N', centerX, centerY - radius - 6);
|
||||||
|
ctx.fillText('S', centerX, centerY + radius + 6);
|
||||||
|
ctx.fillText('E', centerX + radius + 6, centerY);
|
||||||
|
ctx.fillText('W', centerX - radius - 6, centerY);
|
||||||
|
|
||||||
|
// Draw Heatmap wedges
|
||||||
|
for (let d = 0; d < 360; d += 2) {
|
||||||
|
const val = heatmap[d];
|
||||||
|
if (val < 10) continue; // skip very low noise
|
||||||
|
|
||||||
|
const normalized = val / 255;
|
||||||
|
const length = radius * normalized;
|
||||||
|
|
||||||
|
const rad = (d - 90) * (Math.PI / 180); // -90 so 0 is North/Up
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(centerX, centerY);
|
||||||
|
ctx.lineTo(
|
||||||
|
centerX + Math.cos(rad) * length,
|
||||||
|
centerY + Math.sin(rad) * length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Color intensity based on amplitude
|
||||||
|
const hue = 150 - (normalized * 150); // green to redish-yellow
|
||||||
|
ctx.strokeStyle = `hsla(${hue}, 100%, 50%, 0.8)`;
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Best Direction Marker
|
||||||
|
const maxAmpForCanvas = Math.max(...heatmap);
|
||||||
|
const bestDirForCanvas = heatmap.indexOf(maxAmpForCanvas);
|
||||||
|
if (maxAmpForCanvas > dopplerAmpThreshold) {
|
||||||
|
const rad = (bestDirForCanvas - 90) * (Math.PI / 180);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX + Math.cos(rad) * radius, centerY + Math.sin(rad) * radius, 6, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = '#00ff88';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore(); // Restore context to non-rotated state
|
||||||
|
|
||||||
|
// Draw Current Heading Marker (Phone's Forward Direction) -> Always UP
|
||||||
|
if (heading !== null) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(centerX, centerY);
|
||||||
|
ctx.lineTo(centerX, centerY - radius);
|
||||||
|
ctx.strokeStyle = 'rgba(0, 255, 255, 0.6)';
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Outer dot for Forward
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY - radius, 4, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = 'cyan';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}, [heatmap, heading]);
|
||||||
|
|
||||||
|
// Find strongest direction and confidence
|
||||||
|
const maxAmp = Math.max(...heatmap);
|
||||||
|
const bestDirection = heatmap.indexOf(maxAmp);
|
||||||
|
let confidence = 0;
|
||||||
|
if (maxAmp > dopplerAmpThreshold) {
|
||||||
|
const avgAmp = heatmap.reduce((a, b) => a + b, 0) / heatmap.length;
|
||||||
|
confidence = Math.max(0, Math.min(100, Math.round(((maxAmp - avgAmp) / maxAmp) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center space-y-6 px-4">
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Source Finder</h2>
|
||||||
|
<p className="text-zinc-400 text-xs max-w-[250px] mx-auto leading-relaxed">
|
||||||
|
Hold phone upright and rotate around you. Top of screen is forward.
|
||||||
|
</p>
|
||||||
|
<div className="text-3xl font-mono font-bold text-white flex justify-center items-center pt-2">
|
||||||
|
<Compass className="w-6 h-6 mr-3 text-cyan-500" />
|
||||||
|
{heading !== null ? `${Math.round(heading)}°` : '---°'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-[300px] aspect-square bg-zinc-900/40 rounded-full border border-zinc-800 flex items-center justify-center">
|
||||||
|
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center bg-zinc-900 px-6 py-4 rounded-2xl border border-zinc-800 w-full">
|
||||||
|
<div className="space-y-1 text-left">
|
||||||
|
<div className="text-zinc-400 text-xs font-semibold uppercase tracking-wider">Source Dir</div>
|
||||||
|
<div className="text-2xl font-mono text-green-400">
|
||||||
|
{maxAmp > dopplerAmpThreshold ? `${bestDirection}°` : '---°'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-right">
|
||||||
|
<div className="text-zinc-400 text-xs font-semibold uppercase tracking-wider">Confidence</div>
|
||||||
|
<div className="text-xl font-mono text-zinc-300">
|
||||||
|
{maxAmp > dopplerAmpThreshold ? `${confidence}%` : '--%'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSimulated && (
|
||||||
|
<div className="text-[10px] text-orange-400 font-semibold bg-orange-950/40 px-3 py-1.5 rounded-full border border-orange-900/50">
|
||||||
|
Simulated Compass (Hardware Unavailable)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/components/SimpleDetector.tsx
Normal file
208
src/components/SimpleDetector.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useAppStore } from '@/store/useAppStore';
|
||||||
|
import { useDopplerEngine } from '@/hooks/useDopplerEngine';
|
||||||
|
import { ShieldCheck, AlertOctagon, ChevronsUp, ChevronsDown, Minus, Search, FlaskConical } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
getFrequencyData: () => { data: Uint8Array; sampleRate: number; fftSize: number; binCount: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleDetector({ getFrequencyData }: Props) {
|
||||||
|
const { dopplerAmpThreshold, dopplerSpreadThreshold, experimentalDoppler } = useAppStore();
|
||||||
|
|
||||||
|
const [movementState, setMovementState] = useState<string>('clear');
|
||||||
|
|
||||||
|
// Legacy refs (used when experimental mode is OFF)
|
||||||
|
const baselineFreqRef = useRef<number | null>(null);
|
||||||
|
const baselineStartTimeRef = useRef<number | null>(null);
|
||||||
|
const candidateFreqRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Advanced engine (used when experimental mode is ON)
|
||||||
|
const { process: dopplerProcess, reset: dopplerReset } = useDopplerEngine();
|
||||||
|
|
||||||
|
// Reset engine state when toggling modes
|
||||||
|
const prevExperimentalRef = useRef(experimentalDoppler);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevExperimentalRef.current !== experimentalDoppler) {
|
||||||
|
dopplerReset();
|
||||||
|
baselineFreqRef.current = null;
|
||||||
|
baselineStartTimeRef.current = null;
|
||||||
|
candidateFreqRef.current = null;
|
||||||
|
setMovementState('clear');
|
||||||
|
prevExperimentalRef.current = experimentalDoppler;
|
||||||
|
}
|
||||||
|
}, [experimentalDoppler, dopplerReset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let animationFrameId: number;
|
||||||
|
let lastUpdate = 0;
|
||||||
|
|
||||||
|
const loop = (timestamp: number) => {
|
||||||
|
animationFrameId = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
const audioData = getFrequencyData();
|
||||||
|
if (!audioData) return;
|
||||||
|
|
||||||
|
if (experimentalDoppler) {
|
||||||
|
// ---- EXPERIMENTAL: 6-stage pipeline ----
|
||||||
|
const state = dopplerProcess(
|
||||||
|
audioData.data,
|
||||||
|
audioData.sampleRate,
|
||||||
|
audioData.fftSize,
|
||||||
|
audioData.binCount,
|
||||||
|
timestamp,
|
||||||
|
dopplerAmpThreshold,
|
||||||
|
dopplerSpreadThreshold,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (timestamp - lastUpdate > 100) {
|
||||||
|
setMovementState(state);
|
||||||
|
lastUpdate = timestamp;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ---- LEGACY: original single-bin algorithm ----
|
||||||
|
const { data, sampleRate, fftSize, binCount } = audioData;
|
||||||
|
const freqPerBin = sampleRate / fftSize;
|
||||||
|
|
||||||
|
let maxAmp = 0;
|
||||||
|
let currentPeakFreq = 0;
|
||||||
|
|
||||||
|
// Scan frequencies above 6kHz
|
||||||
|
for (let i = 0; i < binCount; i++) {
|
||||||
|
const freq = i * freqPerBin;
|
||||||
|
if (freq >= 6000) {
|
||||||
|
if (data[i] > maxAmp) {
|
||||||
|
maxAmp = data[i];
|
||||||
|
currentPeakFreq = freq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxAmp > dopplerAmpThreshold) {
|
||||||
|
if (baselineFreqRef.current === null) {
|
||||||
|
if (baselineStartTimeRef.current === null) {
|
||||||
|
baselineStartTimeRef.current = timestamp;
|
||||||
|
candidateFreqRef.current = currentPeakFreq;
|
||||||
|
} else {
|
||||||
|
if (Math.abs(currentPeakFreq - (candidateFreqRef.current || 0)) < 50) {
|
||||||
|
if (timestamp - baselineStartTimeRef.current > 3000) {
|
||||||
|
baselineFreqRef.current = candidateFreqRef.current;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baselineStartTimeRef.current = timestamp;
|
||||||
|
candidateFreqRef.current = currentPeakFreq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (Math.abs(currentPeakFreq - baselineFreqRef.current) > 100) {
|
||||||
|
baselineFreqRef.current = null;
|
||||||
|
baselineStartTimeRef.current = timestamp;
|
||||||
|
candidateFreqRef.current = currentPeakFreq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baselineFreqRef.current = null;
|
||||||
|
baselineStartTimeRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamp - lastUpdate > 200) {
|
||||||
|
if (baselineFreqRef.current !== null) {
|
||||||
|
const diff = currentPeakFreq - baselineFreqRef.current;
|
||||||
|
if (diff >= dopplerSpreadThreshold) {
|
||||||
|
setMovementState('moving_towards');
|
||||||
|
} else if (diff <= -dopplerSpreadThreshold) {
|
||||||
|
setMovementState('moving_away');
|
||||||
|
} else {
|
||||||
|
setMovementState('stationary');
|
||||||
|
}
|
||||||
|
} else if (baselineStartTimeRef.current !== null) {
|
||||||
|
setMovementState('locking');
|
||||||
|
} else if (maxAmp > dopplerAmpThreshold) {
|
||||||
|
setMovementState('analyzing');
|
||||||
|
} else {
|
||||||
|
setMovementState('clear');
|
||||||
|
}
|
||||||
|
lastUpdate = timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(animationFrameId);
|
||||||
|
}, [getFrequencyData, dopplerAmpThreshold, dopplerSpreadThreshold, experimentalDoppler, dopplerProcess]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-12 px-4 text-center">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-3xl font-black tracking-tighter">Status</h2>
|
||||||
|
<p className="text-zinc-400 text-sm max-w-[250px] mx-auto leading-relaxed">
|
||||||
|
Monitoring for high-frequency acoustic anomalies ({'>'}6kHz).
|
||||||
|
</p>
|
||||||
|
{experimentalDoppler && (
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-xs text-purple-400 font-semibold">
|
||||||
|
<FlaskConical className="w-3.5 h-3.5" />
|
||||||
|
<span>Experimental Engine Active</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`relative flex items-center justify-center w-64 h-64 rounded-full transition-all duration-500 ${
|
||||||
|
movementState === 'moving_towards' ? 'bg-blue-500/20 scale-105' :
|
||||||
|
movementState === 'moving_away' ? 'bg-red-500/20 scale-105' :
|
||||||
|
movementState === 'stationary' ? 'bg-orange-500/20' :
|
||||||
|
movementState === 'locking' || movementState === 'analyzing' ? 'bg-yellow-500/10' :
|
||||||
|
'bg-green-500/10'
|
||||||
|
}`}>
|
||||||
|
|
||||||
|
<div className={`relative flex flex-col items-center justify-center w-52 h-52 rounded-full border-4 transition-colors duration-500 ${
|
||||||
|
movementState === 'moving_towards' ? 'bg-blue-600 border-blue-400 text-white' :
|
||||||
|
movementState === 'moving_away' ? 'bg-red-600 border-red-400 text-white' :
|
||||||
|
movementState === 'stationary' ? 'bg-orange-600 border-orange-400 text-white' :
|
||||||
|
movementState === 'locking' || movementState === 'analyzing' ? 'bg-zinc-800 border-yellow-500 text-yellow-500' :
|
||||||
|
'bg-zinc-900 border-green-500 text-zinc-500'
|
||||||
|
}`}>
|
||||||
|
{movementState === 'moving_towards' && (
|
||||||
|
<>
|
||||||
|
<ChevronsUp className="w-20 h-20 mb-1 animate-bounce" />
|
||||||
|
<span className="text-xl font-black uppercase tracking-widest text-center leading-tight">Moving<br/>Towards</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{movementState === 'moving_away' && (
|
||||||
|
<>
|
||||||
|
<ChevronsDown className="w-20 h-20 mb-1 animate-bounce" />
|
||||||
|
<span className="text-xl font-black uppercase tracking-widest text-center leading-tight">Moving<br/>Away</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{movementState === 'stationary' && (
|
||||||
|
<>
|
||||||
|
<Minus className="w-20 h-20 mb-1" />
|
||||||
|
<span className="text-xl font-black uppercase tracking-widest text-center leading-tight">Locked<br/>Stationary</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(movementState === 'locking' || movementState === 'analyzing') && (
|
||||||
|
<>
|
||||||
|
<Search className="w-16 h-16 mb-2 animate-pulse" />
|
||||||
|
<span className="text-xl font-black uppercase tracking-widest">Locking...</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{movementState === 'clear' && (
|
||||||
|
<>
|
||||||
|
<ShieldCheck className="w-16 h-16 mb-2 text-zinc-600" />
|
||||||
|
<span className="text-2xl font-black uppercase tracking-widest">Clear</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-900/50 border border-zinc-800 text-zinc-400 px-6 py-4 rounded-2xl h-16 flex items-center justify-center">
|
||||||
|
{movementState === 'moving_towards' && <p className="font-bold text-blue-400">Source is moving CLOSER to you</p>}
|
||||||
|
{movementState === 'moving_away' && <p className="font-bold text-red-400">Source is moving AWAY from you</p>}
|
||||||
|
{movementState === 'stationary' && <p className="font-bold text-orange-400">Source is stationary. Move phone to test.</p>}
|
||||||
|
{(movementState === 'locking' || movementState === 'analyzing') && <p className="font-semibold text-sm text-yellow-500/80">Acquiring signal baseline...</p>}
|
||||||
|
{movementState === 'clear' && <p className="font-semibold text-sm">Listening for high frequencies...</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/components/Spectrogram.tsx
Normal file
105
src/components/Spectrogram.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { useAppStore, BANDS } from '@/store/useAppStore';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
getFrequencyData: () => { data: Uint8Array; sampleRate: number; fftSize: number; binCount: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spectrogram({ getFrequencyData }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const selectedBandId = useAppStore(state => state.selectedBandId);
|
||||||
|
const band = BANDS.find(b => b.id === selectedBandId) || BANDS[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let animationFrameId: number;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
|
|
||||||
|
const audioData = getFrequencyData();
|
||||||
|
if (!audioData) return;
|
||||||
|
|
||||||
|
const displayWidth = canvas.clientWidth;
|
||||||
|
const displayHeight = canvas.clientHeight;
|
||||||
|
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
|
||||||
|
canvas.width = displayWidth;
|
||||||
|
canvas.height = displayHeight;
|
||||||
|
ctx.fillStyle = 'black';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, sampleRate, fftSize, binCount } = audioData;
|
||||||
|
|
||||||
|
// Shift canvas left by 1 pixel (or 2 for speed)
|
||||||
|
const shiftSpeed = 2;
|
||||||
|
const imageData = ctx.getImageData(shiftSpeed, 0, canvas.width - shiftSpeed, canvas.height);
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
// Draw new vertical slice on the right
|
||||||
|
const freqPerBin = sampleRate / fftSize;
|
||||||
|
|
||||||
|
// We map the Y axis from min/max display based on the selected band or full
|
||||||
|
const maxDisplayFreq = band.max;
|
||||||
|
const minDisplayFreq = band.min;
|
||||||
|
|
||||||
|
for (let y = 0; y < canvas.height; y++) {
|
||||||
|
const freq = minDisplayFreq + (canvas.height - y) / canvas.height * (maxDisplayFreq - minDisplayFreq);
|
||||||
|
const binIndex = Math.floor(freq / freqPerBin);
|
||||||
|
|
||||||
|
if (binIndex < binCount) {
|
||||||
|
const val = data[binIndex];
|
||||||
|
const normalized = val / 255;
|
||||||
|
|
||||||
|
// Map to heatmap color
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (normalized < 0.2) {
|
||||||
|
b = normalized * 5 * 255;
|
||||||
|
} else if (normalized < 0.5) {
|
||||||
|
b = 255 - (normalized - 0.2) * 3 * 255;
|
||||||
|
g = (normalized - 0.2) * 3 * 255;
|
||||||
|
} else if (normalized < 0.8) {
|
||||||
|
g = 255;
|
||||||
|
r = (normalized - 0.5) * 3 * 255;
|
||||||
|
} else {
|
||||||
|
g = 255 - (normalized - 0.8) * 5 * 255;
|
||||||
|
r = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'black';
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillRect(canvas.width - shiftSpeed, y, shiftSpeed, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
|
return () => cancelAnimationFrame(animationFrameId);
|
||||||
|
}, [getFrequencyData, band]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div className="flex justify-between px-2 text-zinc-400 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<span>Time →</span>
|
||||||
|
<span>Spectrogram</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full h-64 bg-black rounded-2xl border border-zinc-800 overflow-hidden">
|
||||||
|
<canvas ref={canvasRef} className="w-full h-full" />
|
||||||
|
<div className="absolute top-2 left-2 text-[10px] font-mono text-white/70 bg-black/50 px-1 rounded">
|
||||||
|
{`${band.max / 1000}kHz`}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2 left-2 text-[10px] font-mono text-white/70 bg-black/50 px-1 rounded">
|
||||||
|
{`${band.min / 1000}kHz`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
108
src/hooks/useAudioAnalyzer.ts
Normal file
108
src/hooks/useAudioAnalyzer.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface AudioAnalyzerState {
|
||||||
|
isInitialized: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAudioAnalyzer = (fftSize: number = 8192) => {
|
||||||
|
const [state, setState] = useState<AudioAnalyzerState>({
|
||||||
|
isInitialized: false,
|
||||||
|
isStreaming: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||||
|
|
||||||
|
const init = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
throw new Error("Microphone API not available (requires HTTPS)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioContextCtor = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
const ctx = new AudioContextCtor({ sampleRate: 48000 });
|
||||||
|
|
||||||
|
// iOS requires AudioContext to be resumed synchronously during user interaction
|
||||||
|
if (ctx.state === 'suspended') {
|
||||||
|
await ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: false,
|
||||||
|
autoGainControl: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const analyser = ctx.createAnalyser();
|
||||||
|
analyser.fftSize = fftSize;
|
||||||
|
analyser.smoothingTimeConstant = 0.3;
|
||||||
|
|
||||||
|
const source = ctx.createMediaStreamSource(stream);
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
audioContextRef.current = ctx;
|
||||||
|
analyserRef.current = analyser;
|
||||||
|
streamRef.current = stream;
|
||||||
|
sourceRef.current = source;
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isInitialized: true,
|
||||||
|
isStreaming: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to init audio context', err);
|
||||||
|
setState(s => ({ ...s, error: err.message || 'Microphone access denied' }));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [fftSize]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach(track => track.stop());
|
||||||
|
}
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
audioContextRef.current.close();
|
||||||
|
}
|
||||||
|
setState({
|
||||||
|
isInitialized: false,
|
||||||
|
isStreaming: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [stop]);
|
||||||
|
|
||||||
|
// Expose a method to grab the latest frequency data
|
||||||
|
const getFrequencyData = useCallback(() => {
|
||||||
|
if (!analyserRef.current || !audioContextRef.current) return null;
|
||||||
|
|
||||||
|
const analyser = analyserRef.current;
|
||||||
|
// Uint8Array for performance. Values 0-255.
|
||||||
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: dataArray,
|
||||||
|
sampleRate: audioContextRef.current.sampleRate,
|
||||||
|
fftSize: analyser.fftSize,
|
||||||
|
binCount: analyser.frequencyBinCount
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...state, init, stop, getFrequencyData };
|
||||||
|
};
|
||||||
77
src/hooks/useDeviceOrientation.ts
Normal file
77
src/hooks/useDeviceOrientation.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface OrientationState {
|
||||||
|
isGranted: boolean;
|
||||||
|
heading: number | null; // 0 to 360 relative heading
|
||||||
|
isSimulated: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeviceOrientation = () => {
|
||||||
|
const [state, setState] = useState<OrientationState>({
|
||||||
|
isGranted: false,
|
||||||
|
heading: null,
|
||||||
|
isSimulated: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestPermission = useCallback(async () => {
|
||||||
|
if (typeof (DeviceOrientationEvent as any).requestPermission === 'function') {
|
||||||
|
try {
|
||||||
|
const permissionState = await (DeviceOrientationEvent as any).requestPermission();
|
||||||
|
if (permissionState === 'granted') {
|
||||||
|
setState(s => ({ ...s, isGranted: true }));
|
||||||
|
} else {
|
||||||
|
setState(s => ({ ...s, error: 'Permission denied for orientation' }));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setState(s => ({ ...s, error: err.message || 'Error requesting permission' }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-iOS 13+ devices don't need requestPermission
|
||||||
|
setState(s => ({ ...s, isGranted: true }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.isGranted) return;
|
||||||
|
|
||||||
|
let hasHardwareOrientation = false;
|
||||||
|
|
||||||
|
const handleOrientation = (event: DeviceOrientationEvent) => {
|
||||||
|
// absolute alpha (if available), otherwise standard alpha
|
||||||
|
let dir = event.alpha;
|
||||||
|
|
||||||
|
// On iOS we can sometimes use webkitCompassHeading
|
||||||
|
if ((event as any).webkitCompassHeading !== undefined) {
|
||||||
|
dir = (event as any).webkitCompassHeading;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dir !== null) {
|
||||||
|
hasHardwareOrientation = true;
|
||||||
|
setState(s => ({ ...s, heading: dir, isSimulated: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (hasHardwareOrientation) return;
|
||||||
|
const cx = window.innerWidth / 2;
|
||||||
|
const cy = window.innerHeight / 2;
|
||||||
|
const angleRad = Math.atan2(e.clientY - cy, e.clientX - cx);
|
||||||
|
const angleDeg = (angleRad * 180) / Math.PI;
|
||||||
|
// Map to 0 (Up) - 360 clockwise
|
||||||
|
const simHeading = (angleDeg + 90 + 360) % 360;
|
||||||
|
setState(s => ({ ...s, heading: simHeading, isSimulated: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('deviceorientation', handleOrientation, true);
|
||||||
|
window.addEventListener('mousemove', handleMouseMove, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('deviceorientation', handleOrientation, true);
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove, true);
|
||||||
|
};
|
||||||
|
}, [state.isGranted]);
|
||||||
|
|
||||||
|
return { ...state, requestPermission };
|
||||||
|
};
|
||||||
254
src/hooks/useDopplerEngine.ts
Normal file
254
src/hooks/useDopplerEngine.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advanced Doppler-shift detection engine.
|
||||||
|
*
|
||||||
|
* 6-stage pipeline:
|
||||||
|
* A. Parabolic interpolation for sub-bin peak accuracy
|
||||||
|
* B. Narrow spectral centroid (±5 bins) for stability
|
||||||
|
* C. EMA smoothing on the frequency estimate
|
||||||
|
* D. Robust baseline: median during lock, slow-drift EMA after
|
||||||
|
* E. Hysteresis on direction state transitions
|
||||||
|
* F. Confidence counter (consecutive-frame gating)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DopplerState = 'clear' | 'locking' | 'analyzing' | 'stationary' | 'moving_towards' | 'moving_away';
|
||||||
|
|
||||||
|
interface EngineConfig {
|
||||||
|
ampThreshold: number;
|
||||||
|
spreadThreshold: number;
|
||||||
|
minFreq: number;
|
||||||
|
lockDurationMs: number;
|
||||||
|
emaAlpha: number; // fast EMA for frame smoothing
|
||||||
|
baselineEmaAlpha: number; // slow EMA for post-lock baseline drift
|
||||||
|
hysteresisRatio: number; // exit threshold = spreadThreshold * ratio
|
||||||
|
confidenceFrames: number; // consecutive frames needed to change state
|
||||||
|
centroidWindow: number; // ±bins around peak for centroid
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: EngineConfig = {
|
||||||
|
ampThreshold: 24,
|
||||||
|
spreadThreshold: 12,
|
||||||
|
minFreq: 6000,
|
||||||
|
lockDurationMs: 3000,
|
||||||
|
emaAlpha: 0.15,
|
||||||
|
baselineEmaAlpha: 0.02,
|
||||||
|
hysteresisRatio: 0.6,
|
||||||
|
confidenceFrames: 3,
|
||||||
|
centroidWindow: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
/** Parabolic interpolation on 3 points to find sub-bin peak offset δ ∈ (-0.5, 0.5) */
|
||||||
|
function parabolicInterp(left: number, center: number, right: number): number {
|
||||||
|
const denom = left - 2 * center + right;
|
||||||
|
if (denom === 0) return 0;
|
||||||
|
return 0.5 * (left - right) / denom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute weighted centroid in [lo, hi] range of the data array */
|
||||||
|
function spectralCentroid(data: Uint8Array, centerBin: number, window: number, freqPerBin: number): number {
|
||||||
|
const lo = Math.max(0, centerBin - window);
|
||||||
|
const hi = Math.min(data.length - 1, centerBin + window);
|
||||||
|
|
||||||
|
let sumWeighted = 0;
|
||||||
|
let sumAmp = 0;
|
||||||
|
for (let i = lo; i <= hi; i++) {
|
||||||
|
const amp = data[i];
|
||||||
|
sumWeighted += i * freqPerBin * amp;
|
||||||
|
sumAmp += amp;
|
||||||
|
}
|
||||||
|
return sumAmp > 0 ? sumWeighted / sumAmp : centerBin * freqPerBin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute median of a number array (non-destructive) */
|
||||||
|
function median(arr: number[]): number {
|
||||||
|
if (arr.length === 0) return 0;
|
||||||
|
const sorted = [...arr].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDopplerEngine() {
|
||||||
|
// --- Persistent refs across frames ---
|
||||||
|
const smoothedFreqRef = useRef<number | null>(null);
|
||||||
|
const baselineFreqRef = useRef<number | null>(null);
|
||||||
|
const lockStartRef = useRef<number | null>(null);
|
||||||
|
const lockSamplesRef = useRef<number[]>([]);
|
||||||
|
const directionRef = useRef<DopplerState>('clear');
|
||||||
|
const confidenceRef = useRef<number>(0);
|
||||||
|
const pendingDirectionRef = useRef<DopplerState>('clear');
|
||||||
|
const lastUpdateRef = useRef<number>(0);
|
||||||
|
const configRef = useRef<EngineConfig>({ ...DEFAULT_CONFIG });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call once per animation frame with current audio data + timestamp.
|
||||||
|
* Returns the current DopplerState.
|
||||||
|
*/
|
||||||
|
const process = useCallback((
|
||||||
|
data: Uint8Array,
|
||||||
|
sampleRate: number,
|
||||||
|
fftSize: number,
|
||||||
|
binCount: number,
|
||||||
|
timestamp: number,
|
||||||
|
ampThreshold: number,
|
||||||
|
spreadThreshold: number,
|
||||||
|
): DopplerState => {
|
||||||
|
const cfg = configRef.current;
|
||||||
|
cfg.ampThreshold = ampThreshold;
|
||||||
|
cfg.spreadThreshold = spreadThreshold;
|
||||||
|
|
||||||
|
const freqPerBin = sampleRate / fftSize;
|
||||||
|
const minBin = Math.ceil(cfg.minFreq / freqPerBin);
|
||||||
|
|
||||||
|
// ---- Stage 0: Find raw peak bin above minFreq ----
|
||||||
|
let maxAmp = 0;
|
||||||
|
let peakBin = minBin;
|
||||||
|
for (let i = minBin; i < binCount; i++) {
|
||||||
|
if (data[i] > maxAmp) {
|
||||||
|
maxAmp = data[i];
|
||||||
|
peakBin = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If signal is below threshold, reset everything
|
||||||
|
if (maxAmp <= cfg.ampThreshold) {
|
||||||
|
smoothedFreqRef.current = null;
|
||||||
|
baselineFreqRef.current = null;
|
||||||
|
lockStartRef.current = null;
|
||||||
|
lockSamplesRef.current = [];
|
||||||
|
confidenceRef.current = 0;
|
||||||
|
directionRef.current = 'clear';
|
||||||
|
return 'clear';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stage A: Parabolic interpolation ----
|
||||||
|
const left = peakBin > 0 ? data[peakBin - 1] : data[peakBin];
|
||||||
|
const right = peakBin < binCount - 1 ? data[peakBin + 1] : data[peakBin];
|
||||||
|
const delta = parabolicInterp(left, data[peakBin], right);
|
||||||
|
const interpFreq = (peakBin + delta) * freqPerBin;
|
||||||
|
|
||||||
|
// ---- Stage B: Spectral centroid (±window bins) ----
|
||||||
|
const centroidFreq = spectralCentroid(data, peakBin, cfg.centroidWindow, freqPerBin);
|
||||||
|
|
||||||
|
// Average the interpolated peak and centroid for a blended estimate
|
||||||
|
const rawFreq = (interpFreq + centroidFreq) / 2;
|
||||||
|
|
||||||
|
// ---- Stage C: EMA smoothing ----
|
||||||
|
if (smoothedFreqRef.current === null) {
|
||||||
|
smoothedFreqRef.current = rawFreq;
|
||||||
|
} else {
|
||||||
|
smoothedFreqRef.current = cfg.emaAlpha * rawFreq + (1 - cfg.emaAlpha) * smoothedFreqRef.current;
|
||||||
|
}
|
||||||
|
const smoothed = smoothedFreqRef.current;
|
||||||
|
|
||||||
|
// ---- Stage D: Baseline management ----
|
||||||
|
if (baselineFreqRef.current === null) {
|
||||||
|
// We're in the lock phase
|
||||||
|
if (lockStartRef.current === null) {
|
||||||
|
lockStartRef.current = timestamp;
|
||||||
|
lockSamplesRef.current = [smoothed];
|
||||||
|
directionRef.current = 'locking';
|
||||||
|
return 'locking';
|
||||||
|
}
|
||||||
|
|
||||||
|
lockSamplesRef.current.push(smoothed);
|
||||||
|
|
||||||
|
// Check if candidate samples are stable enough
|
||||||
|
const samples = lockSamplesRef.current;
|
||||||
|
if (samples.length >= 5) {
|
||||||
|
const recentMedian = median(samples.slice(-10));
|
||||||
|
const deviation = Math.abs(smoothed - recentMedian);
|
||||||
|
// If current reading deviates too far from running median, restart lock
|
||||||
|
if (deviation > 80) {
|
||||||
|
lockStartRef.current = timestamp;
|
||||||
|
lockSamplesRef.current = [smoothed];
|
||||||
|
directionRef.current = 'locking';
|
||||||
|
return 'locking';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = timestamp - lockStartRef.current;
|
||||||
|
if (elapsed >= cfg.lockDurationMs) {
|
||||||
|
// Lock complete — set baseline as median of collected samples
|
||||||
|
baselineFreqRef.current = median(lockSamplesRef.current);
|
||||||
|
lockSamplesRef.current = [];
|
||||||
|
directionRef.current = 'stationary';
|
||||||
|
return 'stationary';
|
||||||
|
}
|
||||||
|
|
||||||
|
directionRef.current = 'locking';
|
||||||
|
return 'locking';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-lock: slow-drift the baseline to handle environmental changes
|
||||||
|
// Only drift when we're in stationary state (not actively detecting movement)
|
||||||
|
if (directionRef.current === 'stationary') {
|
||||||
|
baselineFreqRef.current = cfg.baselineEmaAlpha * smoothed + (1 - cfg.baselineEmaAlpha) * baselineFreqRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the tone disappears and reappears at a very different frequency, re-lock
|
||||||
|
if (Math.abs(smoothed - baselineFreqRef.current) > 200) {
|
||||||
|
baselineFreqRef.current = null;
|
||||||
|
lockStartRef.current = timestamp;
|
||||||
|
lockSamplesRef.current = [smoothed];
|
||||||
|
smoothedFreqRef.current = null;
|
||||||
|
confidenceRef.current = 0;
|
||||||
|
directionRef.current = 'locking';
|
||||||
|
return 'locking';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stage E + F: Direction with hysteresis + confidence ----
|
||||||
|
// Only update state at ~30fps throttle
|
||||||
|
if (timestamp - lastUpdateRef.current < 33) {
|
||||||
|
return directionRef.current;
|
||||||
|
}
|
||||||
|
lastUpdateRef.current = timestamp;
|
||||||
|
|
||||||
|
const shift = smoothed - baselineFreqRef.current;
|
||||||
|
const absShift = Math.abs(shift);
|
||||||
|
|
||||||
|
let candidateState: DopplerState;
|
||||||
|
|
||||||
|
// Hysteresis: different thresholds for entering vs exiting movement state
|
||||||
|
const isCurrentlyMoving = directionRef.current === 'moving_towards' || directionRef.current === 'moving_away';
|
||||||
|
const exitThreshold = cfg.spreadThreshold * cfg.hysteresisRatio;
|
||||||
|
|
||||||
|
if (isCurrentlyMoving && absShift < exitThreshold) {
|
||||||
|
candidateState = 'stationary';
|
||||||
|
} else if (absShift >= cfg.spreadThreshold) {
|
||||||
|
candidateState = shift > 0 ? 'moving_towards' : 'moving_away';
|
||||||
|
} else {
|
||||||
|
candidateState = 'stationary';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confidence gating: require N consecutive frames agreeing
|
||||||
|
if (candidateState === pendingDirectionRef.current) {
|
||||||
|
confidenceRef.current++;
|
||||||
|
} else {
|
||||||
|
pendingDirectionRef.current = candidateState;
|
||||||
|
confidenceRef.current = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confidenceRef.current >= cfg.confidenceFrames) {
|
||||||
|
directionRef.current = candidateState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return directionRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Reset the engine state (e.g. when toggling modes) */
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
smoothedFreqRef.current = null;
|
||||||
|
baselineFreqRef.current = null;
|
||||||
|
lockStartRef.current = null;
|
||||||
|
lockSamplesRef.current = [];
|
||||||
|
directionRef.current = 'clear';
|
||||||
|
confidenceRef.current = 0;
|
||||||
|
pendingDirectionRef.current = 'clear';
|
||||||
|
lastUpdateRef.current = 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { process, reset };
|
||||||
|
}
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
68
src/store/useAppStore.ts
Normal file
68
src/store/useAppStore.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export type AppMode = 'simple' | 'detector' | 'radar' | 'spectrogram' | 'history' | 'settings';
|
||||||
|
|
||||||
|
export type FrequencyBand = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BANDS: FrequencyBand[] = [
|
||||||
|
{ id: '10k-14k', name: '10k–14k', min: 10000, max: 14000 },
|
||||||
|
{ id: '14k-17k', name: '14k–17k', min: 14000, max: 17000 },
|
||||||
|
{ id: '17k-20k', name: '17k–20k', min: 17000, max: 20000 },
|
||||||
|
{ id: 'full', name: '6k–24k (Full)', min: 6000, max: 24000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ScanRecord {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
strongestFrequency: number;
|
||||||
|
strongestDirection: number | null; // heading in degrees
|
||||||
|
maxAmplitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
mode: AppMode;
|
||||||
|
setMode: (mode: AppMode) => void;
|
||||||
|
selectedBandId: string;
|
||||||
|
setSelectedBandId: (id: string) => void;
|
||||||
|
history: ScanRecord[];
|
||||||
|
addHistoryRecord: (record: ScanRecord) => void;
|
||||||
|
clearHistory: () => void;
|
||||||
|
dopplerSpreadThreshold: number;
|
||||||
|
setDopplerSpreadThreshold: (val: number) => void;
|
||||||
|
dopplerAmpThreshold: number;
|
||||||
|
setDopplerAmpThreshold: (val: number) => void;
|
||||||
|
experimentalDoppler: boolean;
|
||||||
|
setExperimentalDoppler: (val: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
mode: 'simple',
|
||||||
|
setMode: (mode) => set({ mode }),
|
||||||
|
selectedBandId: '10k-14k',
|
||||||
|
setSelectedBandId: (id) => set({ selectedBandId: id }),
|
||||||
|
history: [],
|
||||||
|
addHistoryRecord: (record) => set((state) => ({ history: [record, ...state.history] })),
|
||||||
|
clearHistory: () => set({ history: [] }),
|
||||||
|
dopplerSpreadThreshold: 12,
|
||||||
|
setDopplerSpreadThreshold: (val) => set({ dopplerSpreadThreshold: val }),
|
||||||
|
dopplerAmpThreshold: 24,
|
||||||
|
setDopplerAmpThreshold: (val) => set({ dopplerAmpThreshold: val }),
|
||||||
|
experimentalDoppler: false,
|
||||||
|
setExperimentalDoppler: (val) => set({ experimentalDoppler: val }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'direction-tone-storage',
|
||||||
|
partialize: (state) => Object.fromEntries(
|
||||||
|
Object.entries(state).filter(([key]) => key !== 'mode')
|
||||||
|
) as Partial<AppState>,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user