.claude/settings.local.json
+12
-0
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..6bd2218
@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(Get-ChildItem -Path \"d:\\\\BirdGO\\\\src\\\\routes\" -Recurse -Filter \"*.svelte\")",
"Bash(Select-Object -ExpandProperty FullName)",
"PowerShell(Get-ChildItem -Path \"d:\\\\BirdGO\" -Recurse -Directory | Where-Object { $_.Name -match \"\\(src|audio|lib\\)\" } | Select-Object -ExpandProperty FullName | head -30)",
"Bash(xargs ls -la)",
"WebFetch(domain:svelte.dev)",
"Bash(deno task *)"
]
}
}
src/lib/history.ts
+51
-0
diff --git a/src/lib/history.ts b/src/lib/history.ts
new file mode 100644
index 0000000..c200487
@@ -0,0 +1,51 @@
import { writable } from "svelte/store";
import type { HistoryEntry, Prediction } from "./types";
const STORAGE_KEY = "birdgo-history";
function load(): HistoryEntry[] {
if (typeof localStorage === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as HistoryEntry[]) : [];
} catch {
return [];
}
}
const store = writable<HistoryEntry[]>(load());
store.subscribe((entries) => {
if (typeof localStorage !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
}
});
let sessionSeen = new Set<string>();
export function startSession() {
sessionSeen = new Set();
}
export function endSession() {
sessionSeen = new Set();
}
export function addDetection(
p: Pick<Prediction, "commonName" | "scientificName" | "confidence">,
) {
if (p.confidence < 0.7) return;
if (sessionSeen.has(p.scientificName)) return;
sessionSeen.add(p.scientificName);
store.update((entries) => [
{
commonName: p.commonName,
scientificName: p.scientificName,
confidence: p.confidence,
detectedAt: new Date().toISOString(),
},
...entries,
]);
}
export const historyStore = { subscribe: store.subscribe };
src/lib/types.ts
+7
-0
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 346369b..5fa4b15 100644
@@ -4,6 +4,13 @@ export interface Prediction {
confidence: number;
}
export interface HistoryEntry {
commonName: string;
scientificName: string;
confidence: number;
detectedAt: string; // ISO 8601
}
export type WorkerInMessage =
| { type: "init" }
| { type: "analyze"; samples: Float32Array };
src/routes/+layout.svelte
+10
-0
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 193ce01..2149dd1 100644
@@ -23,6 +23,15 @@
</svg>
</a>
</li>
<li>
<a href={resolve("/history")} aria-label="History">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"
/>
</svg>
</a>
</li>
</ul>
</nav>
@@ -81,6 +90,7 @@
display: flex;
align-items: center;
justify-content: center;
gap: 3rem;
list-style: none;
a {
src/routes/+page.svelte
+8
-5
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 70b81cd..d8d2663 100644
@@ -1,6 +1,7 @@
<script lang="ts">
import BirdNetWorker from "$lib/birdnet.worker.ts?worker";
import type { Prediction, WorkerOutMessage } from "$lib/types";
import { addDetection, endSession, startSession } from "$lib/history";
const WINDOW_SAMPLES = 144000;
@@ -23,6 +24,7 @@
workerReady = true;
} else if (msg.type === "results") {
predictions = msg.predictions;
for (const p of msg.predictions) addDetection(p);
}
});
worker.postMessage({ type: "init" });
@@ -34,6 +36,7 @@
});
async function startListening() {
startSession();
audioCtx = new AudioContext({ sampleRate: 48000 });
await audioCtx.audioWorklet.addModule("/audio-processor.js");
@@ -84,6 +87,7 @@
stream = null;
audioCtx = null;
sampleBuffer = new Float32Array(0);
endSession();
}
async function toggleListening() {
@@ -147,8 +151,9 @@
border-radius: 0.5rem;
background: linear-gradient(
to right,
color-mix(in srgb, var(--accent) 25%, var(--nav-bg))
calc(var(--pct) * 1%),
color-mix(in srgb, var(--accent) 25%, var(--nav-bg)) calc(
var(--pct) * 1%
),
var(--nav-bg) calc(var(--pct) * 1%)
);
@@ -178,9 +183,7 @@
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.2s,
transform 0.1s;
transition: background 0.2s, transform 0.1s;
svg {
width: 2rem;
src/routes/history/+page.svelte
+77
-0
diff --git a/src/routes/history/+page.svelte b/src/routes/history/+page.svelte
new file mode 100644
index 0000000..a8d433e
@@ -0,0 +1,77 @@
<script lang="ts">
import { historyStore } from "$lib/history";
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
</script>
{#if $historyStore.length === 0}
<p class="empty">No birds detected yet.</p>
{:else}
<ol>
{#each $historyStore as entry (entry.detectedAt + entry.scientificName)}
<li>
<span class="name">{entry.commonName}</span>
<span class="when">
<span>{formatDate(entry.detectedAt)}</span>
<span>{formatTime(entry.detectedAt)}</span>
</span>
</li>
{/each}
</ol>
{/if}
<style>
.empty {
padding: 2rem 1rem;
opacity: 0.5;
text-align: center;
}
ol {
list-style: none;
padding: 1rem;
padding-top: 2rem;
padding-bottom: calc(
var(--nav-height) + env(safe-area-inset-bottom) + 1rem
);
display: flex;
flex-direction: column;
gap: 0.5rem;
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background: var(--nav-bg);
.name {
font-weight: 500;
}
.when {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.1rem;
font-variant-numeric: tabular-nums;
opacity: 0.7;
font-size: 0.85rem;
}
}
}
</style>