feat: implement experimental doppler detection engine and UI toggle

This commit is contained in:
2026-04-26 22:05:32 -04:00
parent ec569252ab
commit 792e205876
21 changed files with 5289 additions and 183 deletions

2
.gitignore vendored
View File

@@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
certificates

25
components.json Normal file
View 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": {}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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

View File

@@ -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;
}
}

View File

@@ -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
View 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',
},
],
}
}

View File

@@ -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 &amp; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &rarr;</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>
);
}

View 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 }

View 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 };
};

View 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 };
};

View 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
View 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
View 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: '10k14k', min: 10000, max: 14000 },
{ id: '14k-17k', name: '14k17k', min: 14000, max: 17000 },
{ id: '17k-20k', name: '17k20k', min: 17000, max: 20000 },
{ id: 'full', name: '6k24k (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>,
}
)
);