Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 83 additions & 3 deletions scripts/seed_test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,34 @@
the last hour so "today"/"last hour" stats aren't empty. Matches the exact
schema scripts/createdb.sh creates.

Also generates one placeholder audio clip per species (for its most recent
detection) at BirdNET-Pi's real extraction path, so web-ui's play button
has something real to play locally -- the same BIRDNET_EXTRACTED_DIR
default web-ui itself uses.

Usage:
python3 scripts/seed_test_data.py # wipes and reseeds
python3 scripts/seed_test_data.py --append # adds on top of existing rows
python3 scripts/seed_test_data.py --days 14 # shorter history
python3 scripts/seed_test_data.py # wipes and reseeds
python3 scripts/seed_test_data.py --append # adds on top of existing rows
python3 scripts/seed_test_data.py --days 14 # shorter history
python3 scripts/seed_test_data.py --no-audio # skip placeholder audio clips
"""
import argparse
import math
import os
import random
import sqlite3
import struct
import wave
from datetime import datetime, timedelta

DB_PATH = os.path.join(os.path.dirname(__file__), 'birds.db')

# Mirrors web-ui's default BIRDNET_EXTRACTED_DIR: BirdSongs lives as a
# sibling of the BirdNET-Pi checkout, never inside the repo itself.
DEFAULT_EXTRACTED_DIR = os.path.normpath(
os.path.join(os.path.dirname(__file__), '..', '..', 'BirdSongs', 'Extracted')
)

# (common_name, scientific_name, relative frequency weight)
REGULAR_SPECIES = [
('Northern Cardinal', 'Cardinalis cardinalis', 10),
Expand Down Expand Up @@ -154,12 +169,74 @@ def generate_rows(days: int):
return rows


def write_placeholder_wav(path: str, seed_text: str, duration: float = 1.2, framerate: int = 22050):
"""Writes a short synthesized tone, distinct per species, so the
web-ui's play button has something real to play during local dev."""
freq = 350 + (abs(hash(seed_text)) % 700)
frame_count = int(duration * framerate)
os.makedirs(os.path.dirname(path), exist_ok=True)
with wave.open(path, 'w') as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(framerate)
frames = bytearray()
for i in range(frame_count):
t = i / framerate
envelope = max(0.0, min(1.0, t * 8, (duration - t) * 8))
sample = int(32767 * 0.3 * envelope * math.sin(2 * math.pi * freq * t))
frames += struct.pack('<h', sample)
wav_file.writeframes(bytes(frames))


def seed_placeholder_audio(con: sqlite3.Connection, extracted_dir: str):
"""Generates one placeholder clip per species (its most recent
detection) at BirdNET-Pi's real extraction path (By_Date/<date>/
<species>/<file>), and repoints that one row's File_Name at the
matching .wav so the DB and the file on disk agree."""
cur = con.cursor()
cur.execute("""
SELECT Com_Name, Date, Time, File_Name FROM detections
ORDER BY Date DESC, Time DESC
""")
seen = set()
updates = []
for com_name, date, time, file_name in cur.fetchall():
if com_name in seen:
continue
seen.add(com_name)

com_name_safe = com_name.replace("'", '').replace(' ', '_')
# ':' is valid in filenames on the Pi's Linux filesystem (where the
# real format comes from) but illegal on Windows dev machines, so
# the placeholder file itself uses a filesystem-safe name.
stem = os.path.splitext(file_name)[0].replace(':', '-')
wav_name = f'{stem}.wav'
full_path = os.path.join(extracted_dir, 'By_Date', date, com_name_safe, wav_name)
write_placeholder_wav(full_path, com_name)
updates.append((wav_name, com_name, date, time))

cur.executemany(
'UPDATE detections SET File_Name = ? WHERE Com_Name = ? AND Date = ? AND Time = ?',
updates,
)
con.commit()
print(f'Generated {len(updates)} placeholder audio clips under {extracted_dir}')


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--db', default=DB_PATH, help='Path to birds.db')
parser.add_argument('--days', type=int, default=28, help='Number of days of history to generate, including today')
parser.add_argument('--append', action='store_true', help="Don't clear existing rows first")
parser.add_argument('--seed', type=int, default=None, help='Random seed, for reproducible output')
parser.add_argument(
'--extracted-dir',
default=os.environ.get('BIRDNET_EXTRACTED_DIR', DEFAULT_EXTRACTED_DIR),
help='Directory to write placeholder audio clips into',
)
parser.add_argument(
'--no-audio', action='store_true', help='Skip generating placeholder audio clips'
)
args = parser.parse_args()

if args.seed is not None:
Expand Down Expand Up @@ -193,6 +270,9 @@ def main():
)
con.commit()

if not args.no_audio:
seed_placeholder_audio(con, args.extracted_dir)

total = cur.execute('SELECT COUNT(*) FROM detections').fetchone()[0]
species = cur.execute('SELECT COUNT(DISTINCT Com_Name) FROM detections').fetchone()[0]
con.close()
Expand Down
3 changes: 2 additions & 1 deletion web-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion web-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"react-dom": "^19.2.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.3.6"
"tw-animate-css": "^1.3.6",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
Expand Down
33 changes: 0 additions & 33 deletions web-ui/src/components/HeroBand.tsx

This file was deleted.

21 changes: 21 additions & 0 deletions web-ui/src/components/ui/input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type * as React from "react";

import { cn } from "#/lib/utils.ts";

function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
className,
)}
{...props}
/>
);
}

export { Input };
125 changes: 125 additions & 0 deletions web-ui/src/components/ui/pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import type * as React from "react";
import { type Button, buttonVariants } from "#/components/ui/button.tsx";
import { cn } from "#/lib/utils.ts";

function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}

function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}

function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}

type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
Comment on lines +38 to +41

function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
}

function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}

function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}

function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}

export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};
Loading