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
|
||||
*.tsbuildinfo
|
||||
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";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
// @ts-expect-error - Next.js dynamic config
|
||||
allowedDevOrigins: ["*.trycloudflare.com", "192.168.68.99", "localhost"],
|
||||
};
|
||||
|
||||
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",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --hostname 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"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",
|
||||
"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": {
|
||||
"@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 "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--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 {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--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 {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -13,8 +13,23 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "DirectionTone",
|
||||
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({
|
||||
@@ -23,11 +38,13 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="en" className={`dark ${geistSans.variable} ${geistMono.variable}`} suppressHydrationWarning>
|
||||
<body
|
||||
suppressHydrationWarning
|
||||
className="antialiased bg-black text-white min-h-[100dvh] overscroll-none"
|
||||
>
|
||||
<div className="min-h-full flex flex-col">{children}</div>
|
||||
</body>
|
||||
</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() {
|
||||
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 (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<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">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<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]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
<PermissionGate audioAnalyzerHooks={audioHooks} orientationHooks={orientationHooks}>
|
||||
<main className="flex-1 flex flex-col p-4 max-w-md mx-auto w-full pt-12 pb-24">
|
||||
|
||||
{/* Header / Band Selector */}
|
||||
{(mode !== 'history' && mode !== 'settings' && mode !== 'simple') && (
|
||||
<div className="mb-8 space-y-4">
|
||||
<h2 className="text-xl font-bold tracking-tight px-1">Target Band</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{BANDS.map(b => (
|
||||
<button
|
||||
key={b.id}
|
||||
onClick={() => setSelectedBandId(b.id)}
|
||||
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'}`}
|
||||
>
|
||||
{b.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Content */}
|
||||
<div className="flex-1 flex flex-col justify-center min-h-[400px]">
|
||||
{mode === 'simple' && (
|
||||
<SimpleDetector getFrequencyData={audioHooks.getFrequencyData} />
|
||||
)}
|
||||
|
||||
{mode === 'detector' && (
|
||||
<DetectorGraph getFrequencyData={audioHooks.getFrequencyData} />
|
||||
)}
|
||||
|
||||
{mode === 'radar' && (
|
||||
<RadarFinder heading={orientationHooks.heading} isSimulated={orientationHooks.isSimulated} getFrequencyData={audioHooks.getFrequencyData} />
|
||||
)}
|
||||
|
||||
{mode === 'spectrogram' && (
|
||||
<Spectrogram getFrequencyData={audioHooks.getFrequencyData} />
|
||||
)}
|
||||
|
||||
{mode === 'history' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-xl font-bold">Scan Log</h2>
|
||||
<Button variant="ghost" size="sm" onClick={clearHistory} className="text-red-400 font-semibold hover:text-red-300 hover:bg-red-950/50">Clear</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{history.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm py-8 text-center">No scans saved.</p>
|
||||
) : (
|
||||
history.map(item => (
|
||||
<div key={item.id} className="bg-zinc-900 border border-zinc-800 rounded-xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</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