Compare commits

...

22 Commits

Author SHA1 Message Date
Samuel Ortion 2516efa805 docs: Add CONTRIBUTING, update INSTALL \ndaemon: update streaming service 2022-08-18 10:07:44 +02:00
Samuel Ortion ca14913aaa Merge branch 'main' of github.com:UncleSamulus/BirdNET-stream 2022-08-18 09:32:20 +02:00
Samuel Ortion 6d89918c6b www: Add .active on nav menu, fix homepage stats 2022-08-18 09:30:17 +02:00
Samuel Ortion 3ac15ba849 conf: Remove instance specific config file 2022-08-18 09:24:46 +02:00
Samuel Ortion c92d8c53ef docs: Update README - Add feature, license sections 2022-08-18 09:23:11 +02:00
Samuel Ortion e74139fe72 style: Adapt responsive design for mobile devices 2022-08-18 05:45:38 +02:00
Samuel Ortion 32a2b92f14 Add dehydrated ssl certificate documentation 2022-08-17 09:18:35 +02:00
Samuel Ortion b90a0cd63a Add base records routes 2022-08-16 19:28:19 +02:00
Samuel Ortion 4e6d4f396b Add timeout to sqlite3 queries 2022-08-16 07:29:58 +02:00
Samuel Ortion c0728d2969 Fix messy nav and main 2022-08-16 07:05:49 +02:00
Samuel Ortion acc51fdfc4 Add plotter and fix miner 2022-08-16 05:21:53 +02:00
Samuel Ortion d56f3806fa Add service status and global manager from webapp 2022-08-15 14:29:59 +02:00
Samuel Ortion e44b8542b0 Add service monitoring and burger menu 2022-08-15 11:42:28 +02:00
Samuel Ortion bd35b4c496 Update install process 2022-08-14 10:33:30 +02:00
Samuel Ortion 8f5388c6f2 Add date info on todays list 2022-08-14 09:40:44 +02:00
Samuel Ortion 128e3f33bb Add today/by date or taxon_id stats and record access 2022-08-14 09:24:35 +02:00
Samuel Ortion f1be35255c
Update issue templates 2022-08-14 09:10:53 +02:00
Samuel Ortion e4488e1918 Generate favicon 2022-08-14 08:10:53 +02:00
Samuel Ortion 2bedbac279 Daemon services update 2022-08-14 08:10:15 +02:00
Samuel Ortion 6b37e1cfc5 Add spectro webpage (not the audio of the server yet) & dataminer script 2022-08-13 16:00:08 +02:00
Samuel Ortion 0399fa085e Add ssl conf 2022-08-13 12:44:01 +02:00
Samuel Ortion 29dcd4c999 Fix install sed and systemd services,working on bullseye test 2022-08-13 11:33:40 +02:00
87 changed files with 4517 additions and 382 deletions

26
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,26 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG] "
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
- Linux distro
- Installation method
Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

6
.gitignore vendored
View File

@ -4,4 +4,8 @@ var/
.env
species_list.txt
species_list.txt
push.sh
config/*.conf

View File

@ -1,35 +0,0 @@
# !/bin/bash
# Extract data generated with BirdNET on record to get relevant informations and record data in sqlite
# Load config file
config_filepath="./config/analyzer.conf"
if [ -f "$config_filepath" ]; then
source "$config_filepath"
else
echo "Config file not found: $config_filepath"
exit 1
fi
# Verify needed prerequisites
if [[ -z ${CHUNK_FOLDER} ]]; then
echo "CHUNK_FOLDER is not set"
exit 1
else
if [[ ! -d "${CHUNK_FOLDER}" ]]; then
echo "CHUNK_FOLDER does not exist: ${CHUNK_FOLDER}"
exit 1
else
if [[ ! -d "${CHUNK_FOLDER}/out" ]]; then
echo "Output dir does not exist: ${CHUNK_FOLDER}/out"
echo "Cannot mine data"
exit 1
fi
fi
fi
fi
function list_all_model_outputs()
{
}

2
.ideas/journal.php Executable file
View File

@ -0,0 +1,2 @@
<?php
echo shell_exec("journalctl -u birdnet_recording -n 10");

3
.ideas/url_escape.php Normal file
View File

@ -0,0 +1,3 @@
<?php
echo urlencode("contact@ortion.fr");

13
CHANGELOG.md Normal file
View File

@ -0,0 +1,13 @@
# Changelog
## v0.0.1-rc
- Integrate BirdNET-Analyzer as submodule
- Add birdnet_recording service
- Add birdnet_analyzis service
- Create symfony webapp
- Extracts BirdNET bird contacts into SQL database
- Add birdnet_stream icecast audio streaming and live spectrogram service https://birdnet/spectro
- Add /today/species and /today/{date}/species/{id} endpoints
- Add records deletion button
- Add systemd status page /status

28
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,28 @@
# Contributing
This project welcomes contributions and suggestions.
You can contribute to this project by contributing to:
* [Issues](https://github.com/UncleSamulus/BirdNET-stream/issues)
* [Discussions](https://github.com/UncleSamulus/BirdNET-stream/discussions)
* [Localization](#Localization)
If you intend to contribute code changes, learn how to [set up your development environment](#Set-up-your-development-environment).
<!--
When contributing template changes, [validate](#Validating-changes) your changes by generating projects with updated templates and running appropriate tests, then file a PR to trigger CI validation. -->
## Set up your development environment
You should follow [./INSTALL.md](./INSTALL.md) to install a working BirdNET-stream system on your system.
## Localization
BirdNET-stream webapp is written in PHP Symfony. The i18n files are stored in the `[./www/translations](./www/translations)` directory.
Any help is welcome to translate the webapp into your language.
## Filing a pull request
All contributions are expected to be reviewed and merged via pull requests into the main branch.

View File

@ -13,6 +13,7 @@ curl -sL https://raw.githubusercontent.com/birdnet-stream/birdnet-stream/master/
- git
- ffmpeg
- python3
- sqlite3
## Install process
@ -44,3 +45,218 @@ source .venv/birdnet-stream
pip install -r requirements.txt
```
### Setup systemd services
```bash
# Copy and Adapt templates
services="birdnet_recording.service birdnet_analyzis.service birdnet_miner.timer birdnet_miner.service"
read -r -a services_array <<<"$services"
for service in ${services_array[@]}; do
sudo cp daemon/systemd/templates/$service /etc/systemd/system/
variables="DIR USER GROUP"
for variable in $variables; do
sudo sed -i "s|<$variable>|${!variable}|g" /etc/systemd/system/$service
done
done
# Enable services
sudo systemctl daemon-reload
sudo systemctl enable --now birdnet_recording.service birdnet_analyzis.service birdnet_miner.timer
```
#### Check if services are working
```bash
# Sercices status
sudo systemctl status birdnet_recording.service birdnet_analyzis.service
# Timers status
sudo systemctl status birdnet_miner.timer
```
```bash
# BirdNET-stream logs
sudo journalctl -feu {birdnet_recording,birdnet_analyzis}.service
```
## Setup BirdNET-stream symfony webapp
### Install php 8.1
```bash
# Remove previously installed php version
sudo apt remove --purge php*
# Install required packages for php
sudo apt install -y lsb-release ca-certificates apt-transport-https software-properties-common gnupg2
# Get php package from sury repo
echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/sury-php.list
sudo wget -qO - https://packages.sury.org/php/apt.gpg | sudo gpg --no-default-keyring --keyring gnupg-ring:/etc/apt/trusted.gpg.d/debian-php-8.gpg --import
sudo chmod 644 /etc/apt/trusted.gpg.d/debian-php-8.gpg
sudo apt update && sudo apt upgrade -y
sudo apt install php8.1
# Install and enable php-fpm
sudo apt install php8.1-fpm
sudo systemctl enable php8.1-fpm
# Install php packages
sudo apt-get install php8.1-{sqlite3,curl,intl}
```
### Install composer
```bash
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"\nphp -r "if (hash_file('sha384', 'composer-setup.php') === '55ce33d7678c5a611085589f1f3ddf8b3c52d662cd01d4ba75c0ee0459970c2200a51f492d557530c71c15d8dba01eae') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"\nphp composer-setup.php\nphp -r "unlink('composer-setup.php');"
sudo mv /composer.phar /usr/local/bin/composer
```
### Install webapp packages
```bash
cd www
composer install
```
### Install nodejs and npm
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
```
```bash
nvm install 16
nvm use 16
```
```bash
sudo dnf install npm
```
```bash
sudo npm install -g yarn
```
```bash
yarn build
```
## Setup audio streaming with icecast and ffmpeg for live spectrogram
Install icecast:
```bash
sudo apt-get install icecast2
```
Modify icecast password:
```xml
[...]
<authentication>
<!-- Sources log in with username 'source' -->
<source-password>secret</source-password>
<!-- Relays log in with username 'relay' -->
<relay-password>secret</relay-password>
<!-- Admin logs in with the username given below -->
<admin-user>birdnet</admin-user>
<admin-password>secret</admin-password>
</authentication>
[...]
```
Launch and enable icecast:
```bash
sudo systemctl enable --now icecast2
```
Adapt `config/analyzer.conf` to this configuration:
```conf
ICECAST_USER=source
ICECAST_PASSWORD=secret # change this to the password you set above
ICECAST_PORT=8000
ICECAST_HOST=localhost
ICECAST_MOUNT=stream
```
Launch and enable audio streamer daemon:
```bash
sudo systemctl enable --now birdnet_streaming.service
```
Add a reverse proxy to nginx to allow https
```nginx
server {
[...]
location /stream {
proxy_pass http://localhost:8000/birdnet;
}
[...]
}
```
## Setup https certificates with dehydrated (only for public instances)
```bash
sudo apt-get install dehydrated
````
Edit `/etc/dehydrated/domains.txt` and add your domain name.
```bash
sudo vim /etc/dehydrated/domains.txt
```
Add acme-challenges alias to your nginx config:
```bash
server {
[...]
location /.well-known/acme-challenge {
alias /var/www/html/.well-known/acme-challenge;
allow all;
}
}
```
Create acme-challenge directory:
```bash
sudo mkdir -p /var/www/html/.well-known/acme-challenge
```
Adapt `/etc/dehydrated/config`, by adding this folder to the `WELLKNOWN` path:
```bash
WELLKNOWN = "/var/www/html/.well-known/acme-challenge"
```
Register to certificate issuer and accept conditions and terms:
```bash
dehydrated --register --accept-terms
```
Generate certificates:
```bash
dehydrated -c
```
Add dehydrated cron
```bash
sudo crontab -e
```
```bash
00 00 01 * * dehydrated -c
```
(This updates the certicates every first day of the month, feel free to adapt to your needs.)

View File

@ -8,21 +8,45 @@
## Introduction
BirdNET-stream record sound on Linux computer and analyze it with the help of the BirdNET algorithm. It extracts most interesting bird songs and calls accessible in a webapp.
BirdNET-stream records sound 24/7 on any Linux computer with a microphone and analyze it using BirdNET algorithm by [**@kahst**](https://github.com/kahst).
## Install
Bird contacts are stored in a database and are made accessible in a webapp.
## Features
- 24/7 recording and [BirdNET-Analyzer](https://github.com/kahst/BirdNET-Analyzer) analysis of sound
- Live audio streaming and live spectrogram visualization from web browser
- Bird contacts saved into a SQL database
- Web Interface for system monitoring, data analysis and visualization
## Requirements
BirdNET-stream aims to be able to run on any 64-bit Linux computer.
It has been tested on Fedora and Debian.
It should work on a Raspberry Pi (or other Single Board Computer) with a USB microphone or Sound Card (not tested yet).
## Installation
On debian based system, you can install BirdNET-stream with the following command:
```bash
curl -sL https://raw.githubusercontent.com/birdnet-stream/birdnet-stream/master/install.sh | bash
curl -sL https://raw.githubusercontent.com/UncleSamulus/BirdNET-stream/main/install.sh | bash
```
For finer control, or to adapt to your system, you can follow the instructions in the [INSTALL.md](./INSTALL.md) file.
## Usage
## Acknoledgements
- BirdNET-stream web application can be accessed on any web browser at [https://birdnet.home](https://birdnet.home), from your local network, or at any other hostname you set in nginx configuration, if your public IP is accessible from the internet.
- See the species detected
## Acknoledgments
- [BirdNET](https://birdnet.cornell.edu) on which this project relies
- [BirdNET-Pi](https://birdnetpi.com) the great inspiration of this project
## License
BirdNET-stream is licensed under the GNU General Public License v3.0, see [./LICENSE](./LICENSE) for more details.

2
TODO Normal file
View File

@ -0,0 +1,2 @@
- Fix clean script
- Fix service manager

View File

@ -3,13 +3,13 @@
# Coordinates of the recorder
LATITUDE="47.87842"
LONGITUDE="0.21826"
LOCATION="Maison ORTION - Saint-Gervais-en-Belin"
# Species selection list
SPECIES_LIST="./config/species_list.txt"
# Minimal confidence threshold
CONFIDENCE=0.25
CONFIDENCE=0.1
# Recording duration (in seconds)
RECORDING_DURATION=15
RECORDING_AMPLIFICATION=1.5
# Chunk folder location
CHUNK_FOLDER="./var/chunks"
# Audio recording device (pulseaudio)
@ -18,3 +18,6 @@ AUDIO_DEVICE="default"
PYTHON_VENV="./.venv/birdnet-stream"
# Database location
DATABASE="./var/db.sqlite"
DAEMON_USER="birdnet"
DAEMON_PASSWORD="secret"

View File

@ -1,189 +0,0 @@
Acanthis cabaret_Lesser Redpoll
Accipiter nisus_Eurasian Sparrowhawk
Acrocephalus palustris_Marsh Warbler
Acrocephalus schoenobaenus_Sedge Warbler
Acrocephalus scirpaceus_Eurasian Reed Warbler
Actitis hypoleucos_Common Sandpiper
Aegithalos caudatus_Long-tailed Tit
Aix galericulata_Mandarin Duck
Alauda arvensis_Eurasian Skylark
Alcedo atthis_Common Kingfisher
Alectoris rufa_Red-legged Partridge
Alopochen aegyptiaca_Egyptian Goose
Anas acuta_Northern Pintail
Anas crecca_Green-winged Teal
Anas platyrhynchos_Mallard
Anser albifrons_Greater White-fronted Goose
Anser anser_Graylag Goose
Anthus petrosus_Rock Pipit
Anthus pratensis_Meadow Pipit
Anthus spinoletta_Water Pipit
Anthus trivialis_Tree Pipit
Apus apus_Common Swift
Aquila chrysaetos_Golden Eagle
Ardea alba_Great Egret
Ardea cinerea_Gray Heron
Ardea purpurea_Purple Heron
Arenaria interpres_Ruddy Turnstone
Aythya ferina_Common Pochard
Aythya fuligula_Tufted Duck
Aythya marila_Greater Scaup
Branta bernicla_Brant
Branta canadensis_Canada Goose
Bubulcus ibis_Cattle Egret
Bucephala clangula_Common Goldeneye
Buteo buteo_Common Buzzard
Calidris alpina_Dunlin
Calidris melanotos_Pectoral Sandpiper
Calidris pugnax_Ruff
Carduelis carduelis_European Goldfinch
Certhia brachydactyla_Short-toed Treecreeper
Certhia familiaris_Eurasian Treecreeper
Cettia cetti_Cetti's Warbler
Charadrius dubius_Little Ringed Plover
Charadrius hiaticula_Common Ringed Plover
Chlidonias hybrida_Whiskered Tern
Chloris chloris_European Greenfinch
Chroicocephalus ridibundus_Black-headed Gull
Ciconia ciconia_White Stork
Cinclus cinclus_White-throated Dipper
Circaetus gallicus_Short-toed Snake-Eagle
Circus aeruginosus_Eurasian Marsh-Harrier
Circus pygargus_Montagu's Harrier
Cisticola juncidis_Zitting Cisticola
Coccothraustes coccothraustes_Hawfinch
Columba livia_Rock Pigeon
Columba oenas_Stock Dove
Columba palumbus_Common Wood-Pigeon
Corvus corax_Common Raven
Corvus corone_Carrion Crow
Corvus frugilegus_Rook
Corvus monedula_Eurasian Jackdaw
Coturnix coturnix_Common Quail
Cuculus canorus_Common Cuckoo
Curruca communis_Greater Whitethroat
Curruca curruca_Lesser Whitethroat
Curruca undata_Dartford Warbler
Cyanistes caeruleus_Eurasian Blue Tit
Cygnus olor_Mute Swan
Delichon urbicum_Common House-Martin
Dendrocopos major_Great Spotted Woodpecker
Dendrocoptes medius_Middle Spotted Woodpecker
Dryobates minor_Lesser Spotted Woodpecker
Dryocopus martius_Black Woodpecker
Egretta garzetta_Little Egret
Emberiza calandra_Corn Bunting
Emberiza cirlus_Cirl Bunting
Emberiza citrinella_Yellowhammer
Emberiza schoeniclus_Reed Bunting
Erithacus rubecula_European Robin
Falco peregrinus_Peregrine Falcon
Falco subbuteo_Eurasian Hobby
Falco tinnunculus_Eurasian Kestrel
Ficedula hypoleuca_European Pied Flycatcher
Fringilla coelebs_Common Chaffinch
Fringilla montifringilla_Brambling
Fulica atra_Eurasian Coot
Gallinago gallinago_Common Snipe
Gallinula chloropus_Eurasian Moorhen
Garrulus glandarius_Eurasian Jay
Grus grus_Common Crane
Haematopus ostralegus_Eurasian Oystercatcher
Himantopus himantopus_Black-winged Stilt
Hippolais polyglotta_Melodious Warbler
Hirundo rustica_Barn Swallow
Ichthyaetus melanocephalus_Mediterranean Gull
Lanius collurio_Red-backed Shrike
Larus argentatus_Herring Gull
Larus canus_Common Gull
Larus fuscus_Lesser Black-backed Gull
Larus marinus_Great Black-backed Gull
Larus michahellis_Yellow-legged Gull
Limosa lapponica_Bar-tailed Godwit
Limosa limosa_Black-tailed Godwit
Linaria cannabina_Eurasian Linnet
Locustella naevia_Common Grasshopper-Warbler
Lophophanes cristatus_Crested Tit
Loxia curvirostra_Red Crossbill
Lullula arborea_Wood Lark
Luscinia megarhynchos_Common Nightingale
Luscinia svecica_Bluethroat
Mareca penelope_Eurasian Wigeon
Mareca strepera_Gadwall
Mergus merganser_Common Merganser
Milvus migrans_Black Kite
Milvus milvus_Red Kite
Morus bassanus_Northern Gannet
Motacilla alba_White Wagtail
Motacilla cinerea_Gray Wagtail
Motacilla flava_Western Yellow Wagtail
Muscicapa striata_Spotted Flycatcher
Numenius arquata_Eurasian Curlew
Nycticorax nycticorax_Black-crowned Night-Heron
Oenanthe oenanthe_Northern Wheatear
Oriolus oriolus_Eurasian Golden Oriole
Pandion haliaetus_Osprey
Panurus biarmicus_Bearded Reedling
Parus major_Great Tit
Passer domesticus_House Sparrow
Passer montanus_Eurasian Tree Sparrow
Perdix perdix_Gray Partridge
Periparus ater_Coal Tit
Pernis apivorus_European Honey-buzzard
Phalacrocorax carbo_Great Cormorant
Phasianus colchicus_Ring-necked Pheasant
Phoenicurus ochruros_Black Redstart
Phoenicurus phoenicurus_Common Redstart
Phylloscopus bonelli_Western Bonelli's Warbler
Phylloscopus collybita_Common Chiffchaff
Phylloscopus ibericus_Iberian Chiffchaff
Phylloscopus trochilus_Willow Warbler
Pica pica_Eurasian Magpie
Picus viridis_Eurasian Green Woodpecker
Pluvialis apricaria_European Golden-Plover
Pluvialis squatarola_Black-bellied Plover
Podiceps cristatus_Great Crested Grebe
Poecile montanus_Willow Tit
Poecile palustris_Marsh Tit
Prunella modularis_Dunnock
Psittacula krameri_Rose-ringed Parakeet
Pyrrhocorax pyrrhocorax_Red-billed Chough
Pyrrhula pyrrhula_Eurasian Bullfinch
Rallus aquaticus_Water Rail
Recurvirostra avosetta_Pied Avocet
Regulus ignicapilla_Common Firecrest
Regulus regulus_Goldcrest
Remiz pendulinus_Eurasian Penduline-Tit
Riparia riparia_Bank Swallow
Saxicola rubetra_Whinchat
Saxicola rubicola_European Stonechat
Serinus serinus_European Serin
Sitta europaea_Eurasian Nuthatch
Spatula clypeata_Northern Shoveler
Spatula querquedula_Garganey
Spinus spinus_Eurasian Siskin
Sterna hirundo_Common Tern
Sternula albifrons_Little Tern
Streptopelia decaocto_Eurasian Collared-Dove
Streptopelia turtur_European Turtle-Dove
Strix aluco_Tawny Owl
Sturnus vulgaris_European Starling
Sylvia atricapilla_Eurasian Blackcap
Sylvia borin_Garden Warbler
Tachybaptus ruficollis_Little Grebe
Tadorna tadorna_Common Shelduck
Thalasseus sandvicensis_Sandwich Tern
Tringa erythropus_Spotted Redshank
Tringa glareola_Wood Sandpiper
Tringa nebularia_Common Greenshank
Tringa ochropus_Green Sandpiper
Tringa totanus_Common Redshank
Troglodytes troglodytes_Eurasian Wren
Turdus iliacus_Redwing
Turdus merula_Eurasian Blackbird
Turdus philomelos_Song Thrush
Turdus pilaris_Fieldfare
Turdus viscivorus_Mistle Thrush
Tyto alba_Barn Owl
Uria aalge_Common Murre
Vanellus vanellus_Northern Lapwing

View File

@ -1,6 +1,13 @@
#! /usr/bin/env bash
set -e
DEBUG=${DEBUG:-1}
debug() {
if [ $DEBUG -eq 1 ]; then
echo "$1"
fi
}
config_filepath="./config/analyzer.conf"
if [ -f "$config_filepath" ]; then
@ -69,6 +76,7 @@ analyze_chunk() {
date=$(echo $chunk_name | cut -d'_' -f2)
week=$(./daemon/weekof.sh $date)
$PYTHON_EXECUTABLE ./analyzer/analyze.py --i $chunk_path --o "$output_dir/model.out.csv" --lat $LATITUDE --lon $LONGITUDE --week $week --min_conf $CONFIDENCE --threads 4 --rtype csv
debug "Model output written to $output_dir/model.out.csv"
}
# Perform audio chunk analysis on all recorded chunks
@ -80,6 +88,8 @@ analyze_chunks() {
done
}
check_prerequisites
# Get list of current chunk in working directory
chunks=$(get_chunk_list)

View File

@ -49,12 +49,15 @@ junk() {
# Get all empty treatment directories
junk="$junk $(find ${CHUNK_FOLDER}/out -type d -empty)"
# Get all empty record directories
treatement_folder=$(find "${CHUNK_FOLDER}/out" -type d ! -empty)
for folder in $treatement_folder; do
if ! $(mem $folder $junk) && $(no_bird_in_model_output $folder); then
junk="$junk $folder"
fi
done
treatement_folder=$(find -wholename "${CHUNK_FOLDER}/out/*" -type d ! -empty)
if [[ ! -z ${treatement_folder} ]]; then
for folder in $treatement_folder; do
echo $folder
if [[ ! $(mem $folder $junk) = "true" ]] && $(no_bird_in_model_output $folder); then
junk="$junk $folder"
fi
done
fi
echo "$junk"
}

50
daemon/birdnet_manager.sh Executable file
View File

@ -0,0 +1,50 @@
#! /usr/bin/env bash
# inspired by https://unix.stackexchange.com/questions/47132/execute-shell-script-from-php-as-root-user
set -e
# set -x
config_filepath="./config/analyzer.conf"
if [ -f "$config_filepath" ]; then
source "$config_filepath"
else
echo "Config file not found: $config_filepath"
# exit 1
fi
if [[ -z $DAEMON_USER ]]
then
echo "DAEMON_USER is not set"
# exit 1
fi
if [[ -z $DAEMON_PASSWORD ]]
then
echo "DAEMON_PASSWORD is not set"
# exit 1
fi
SERVICES="$(sudo -S <<< $DAEMON_PASSWORD ls /etc/systemd/system/ | grep 'birdnet')"
DEBUG=${DEBUG:-0}
debug() {
if [ $DEBUG -eq 1 ]; then
echo "$1"
fi
}
manage() {
action=$1
if [[ -z $2 ]]; then
services=$SERVICES
else
services=$2
fi
debug "$action birdnet services"
# sshpass -p $DAEMON_PASSWORD sudo -S -u $DAEMON_USER sudo systemctl $action $services
sudo systemctl $action $services
echo "done"
}
manage $1 $2

119
daemon/birdnet_miner.sh Executable file
View File

@ -0,0 +1,119 @@
#! /usr/bin/env bash
# Extract observations from a model output folder
#
DEBUG=${DEBUG:-1}
set -e
# set -x
debug() {
if [ $DEBUG -eq 1 ]; then
echo "$1"
fi
}
# Load bash library to deal with BirdNET-stream database
source ./daemon/database/scripts/database.sh
# Load config
source ./config/analyzer.conf
# Check config
if [[ -z ${CHUNK_FOLDER} ]]; then
echo "CHUNK_FOLDER is not set"
exit 1
else
if [[ ! -d ${CHUNK_FOLDER}/out ]]; then
echo "CHUNK_FOLDER does not exist: ${CHUNK_FOLDER}/out"
echo "Cannot extract observations."
exit 1
fi
fi
if [[ -z ${LATITUDE} ]]; then
echo "LATITUDE is not set"
exit 1
fi
if [[ -z ${LONGITUDE} ]]; then
echo "LONGITUDE is not set"
exit 1
fi
model_outputs() {
ls ${CHUNK_FOLDER}/out/*/model.out.csv
}
source_wav() {
model_output_path=$1
model_output_dir=$(dirname $model_output_path)
source_wav=$(basename $model_output_dir | rev | cut --complement -d"." -f1 | rev)
echo $source_wav
}
record_datetime() {
source_wav=$1
source_base=$(basename $source_wav .wav)
record_date=$(echo $source_base | cut -d"_" -f2)
record_time=$(echo $source_base | cut -d"_" -f3)
YYYY=$(echo $record_date | cut -c 1-4)
MM=$(echo $record_date | cut -c 5-6)
DD=$(echo $record_date | cut -c 7-8)
HH=$(echo $record_time | cut -c 1-2)
MI=$(echo $record_time | cut -c 3-4)
SS=$(echo $record_time | cut -c 5-6)
SSS="000"
date="$YYYY-$MM-$DD $HH:$MI:$SS.$SSS"
echo $date
}
save_observations() {
model_output_path=$1
source_audio=$(source_wav $model_output_path)
debug "Audio source: $source_audio"
observations=$(cat $model_output_path | tail -n +2)
IFS=$'\n'
for observation in $observations; do
if [[ -z "$observation" ]]; then
continue
fi
# debug "Observation: $observation"
start=$(echo "$observation" | cut -d"," -f1)
end=$(echo "$observation" | cut -d"," -f2)
scientific_name=$(echo "$observation" | cut -d"," -f3)
common_name=$(echo "$observation" | cut -d"," -f4)
confidence=$(echo "$observation" | cut -d"," -f5)
debug "Observation: $scientific_name ($common_name) from $start to $end with confidence $confidence"
taxon_id=$(get_taxon_id "$scientific_name")
if [[ -z $taxon_id ]]; then
debug "Taxon not found: $scientific_name"
debug "Inserting taxon..."
insert_taxon "$scientific_name" "$common_name"
taxon_id=$(get_taxon_id "$scientific_name")
fi
location_id=$(get_location_id "$LATITUDE" "$LONGITUDE")
if [[ -z $location_id ]]; then
debug "Location not found: $LATITUDE, $LONGITUDE"
debug "Inserting location..."
insert_location "$LATITUDE" "$LONGITUDE"
location_id=$(get_location_id "$LATITUDE" "$LONGITUDE")
fi
datetime=$(record_datetime $source_audio)
if [[ $(observation_exists "$source_audio" "$start" "$end" "$taxon_id" "$location_id") -eq 1 ]]; then
debug "Observation already exists: $source_audio, $start, $end, $taxon_id, $location_id"
else
debug "Inserting observation: $source_audio, $start, $end, $taxon_id, $location_id, $datetime"
insert_observation "$source_audio" "$start" "$end" "$taxon_id" "$location_id" "$confidence" "$datetime"
fi
done
}
main() {
# # Remove all junk observations
# ./daemon/birdnet_clean.sh
# Get model outputs
for model_output in $(model_outputs); do
save_observations $model_output
done
}
main

View File

@ -1,9 +1,38 @@
#! /usr/bin/env bash
record_chunk() {
DEBUG=${DEBUG:-0}
export PULSE_RUNTIME_PATH="/run/user/$(id -u)/pulse/"
debug() {
if [ $DEBUG -eq 1 ]; then
echo "$1"
fi
}
check_folder() {
if [[ ! -d "${CHUNK_FOLDER}" ]]; then
debug "CHUNK_FOLDER does not exist: ${CHUNK_FOLDER}"
debug "Creating recording dir"
mkdir -p "${CHUNK_FOLDER}/in"
fi
}
record_loop() {
DEVICE=$1
DURATION=$2
ffmpeg -f pulse -i ${DEVICE} -t ${DURATION} -vn -acodec pcm_s16le -ac 1 -ar 48000 file:${CHUNK_FOLDER}/in/birdnet_$(date "+%Y%m%d_%H%M%S").wav
debug "New recording loop."
while true; do
record $DEVICE $DURATION
done
}
record() {
DEVICE=$1
DURATION=$2
debug "Recording from $DEVICE for $DURATION seconds"
ffmpeg -nostdin -hide_banner -loglevel error -nostats -f pulse -i ${DEVICE} -t ${DURATION} -vn -acodec pcm_s16le -ac 1 -ar 48000 -af "volume=$RECORDING_AMPLIFY" file:${CHUNK_FOLDER}/in/birdnet_$(date "+%Y%m%d_%H%M%S").wav
}
config_filepath="./config/analyzer.conf"
@ -15,6 +44,8 @@ else
exit 1
fi
check_folder
[ -z $RECORDING_DURATION ] && RECORDING_DURATION=15
if [[ -z $AUDIO_DEVICE ]]; then
@ -22,16 +53,4 @@ if [[ -z $AUDIO_DEVICE ]]; then
exit 1
fi
check_folder() {
if [[ ! -d "${CHUNK_FOLDER}" ]]; then
echo "CHUNK_FOLDER does not exist: ${CHUNK_FOLDER}"
echo "Creating recording dir"
mkdir -p "${CHUNK_FOLDER}/in"
fi
}
check_folder
while true; do
record_chunk $AUDIO_DEVICE $RECORDING_DURATION
done
record_loop $AUDIO_DEVICE $RECORDING_DURATION

51
daemon/birdnet_streaming.sh Executable file
View File

@ -0,0 +1,51 @@
#! /usr/bin/env bash
DEBUG=${DEBUG:-1}
export PULSE_RUNTIME_PATH="/run/user/$(id -u)/pulse/"
debug() {
if [ $DEBUG -eq 1 ]; then
echo "$1"
fi
}
stream() {
DEVICE=$1
debug "Launching audio stream from $DEVICE at icecast://source:secret@$ICECAST_HOST:$ICECAST_PORT/$ICECAST_MOUNT"
ffmpeg -nostdin -hide_banner -loglevel error -nostats \
-f pulse -i $DEVICE -vn -acodec libmp3lame -ac 1 -ar 48000 -content_type 'audio/mpeg' \
-f mp3 "icecast://source:${ICECAST_PASSWORD}@${ICECAST_HOST}:${ICECAST_PORT}/${ICECAST_MOUNT}" -listen 1
}
config_filepath="./config/analyzer.conf"
if [ -f "$config_filepath" ]; then
source "$config_filepath"
else
echo "Config file not found: $config_filepath"
exit 1
fi
if [[ -z $AUDIO_DEVICE ]]; then
echo "AUDIO_DEVICE is not set"
exit 1
fi
if [[ -z $ICECAST_HOST ]]; then
echo "ICECAST_HOST is not set"
exit 1
fi
if [[ -z $ICECAST_PORT ]]; then
echo "ICECAST_PORT is not set"
exit 1
fi
if [[ -z $ICECAST_MOUNT ]]; then
echo "ICECAST_MOUNT is not set"
exit 1
fi
if [[ -z $ICECAST_PASSWORD ]]; then
echo "ICECAST_PASSWORD is not set"
exit 1
fi
stream $AUDIO_DEVICE

View File

@ -0,0 +1,36 @@
#! /usr/bin/env bash
# SQLite library to deal with BirdNET-stream database
set -e
source ./config/analyzer.conf
# Create database in case it was not created yet
./daemon/database/scripts/create.sh
DATABASE=${DATABASE:-"./var/db.sqlite"}
get_location_id() {
sqlite3 -cmd ".timeout 1000" $DATABASE "SELECT location_id FROM location WHERE latitude=$1 AND longitude=$2"
}
get_taxon_id() {
sqlite3 -cmd ".timeout 1000" $DATABASE "SELECT taxon_id FROM taxon WHERE scientific_name='$1'"
}
insert_taxon() {
sqlite3 -cmd ".timeout 1000" $DATABASE "INSERT INTO taxon (scientific_name, common_name) VALUES (\"$1\", \"$2\")"
}
insert_location() {
sqlite3 -cmd ".timeout 1000" $DATABASE "INSERT INTO location (latitude, longitude) VALUES ($1, $2)"
}
insert_observation() {
sqlite3 -cmd ".timeout 1000" $DATABASE "INSERT INTO observation (audio_file, start, end, taxon_id, location_id, confidence, date) VALUES ('$1', '$2', '$3', '$4', '$5', '$6', '$7')"
}
# Check if the observation already exists in the database
observation_exists() {
sqlite3 -cmd ".timeout 1000" $DATABASE "SELECT EXISTS(SELECT observation_id FROM observation WHERE audio_file='$1' AND start='$2' AND end='$3' AND taxon_id='$4' AND location_id='$5')"
}

View File

@ -7,23 +7,24 @@ CREATE TABLE IF NOT EXISTS taxon (
common_name TEXT NOT NULL
);
/** Locality table */
CREATE TABLE IF NOT EXISTS locality (
locality_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
/** Location table */
CREATE TABLE IF NOT EXISTS location (
location_id INTEGER PRIMARY KEY,
latitude REAL NOT NULL,
longitude REAL NOT NULL
);
/** Observation table */
CREATE TABLE IF NOT EXISTS observation (
observation_id INTEGER PRIMARY KEY,
taxon_id INTEGER NOT NULL,
locality_id INTEGER NOT NULL,
date TEXT NOT NULL,
time TEXT NOT NULL,
notes TEXT,
confidence REAL NOT NULL,
`observation_id` INTEGER PRIMARY KEY,
`audio_file` TEXT NOT NULL,
`start` REAL NOT NULL,
`end` REAL NOT NULL,
`taxon_id` INTEGER NOT NULL,
`location_id` INTEGER NOT NULL,
`date` TEXT NOT NULL,
`notes` TEXT,
`confidence` REAL NOT NULL,
FOREIGN KEY(taxon_id) REFERENCES taxon(taxon_id),
FOREIGN KEY(locality_id) REFERENCES locality(locality_id)
FOREIGN KEY(location_id) REFERENCES location(location_id)
);

13
daemon/notify/apprise.sh Executable file
View File

@ -0,0 +1,13 @@
#! /usr/bin/env bash
send() {
message=$1
if [ -z "$message" ]; then
echo "No message to send"
exit 1
fi
apprise -vv -t "BirdNET-stream" -b "$message" \
--config "./config/apprise.conf"
}
send $1

View File

120
daemon/plotter/chart.py Executable file
View File

@ -0,0 +1,120 @@
#! /usr/bin/env python3
from curses import def_prog_mode
import sqlite3
from xml.sax.handler import feature_external_ges
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
import seaborn as sns
from datetime import datetime
CONFIG = {
"readings": 10,
"palette": "Greens",
}
db = None
def get_database():
global db
if db is None:
db = sqlite3.connect('/home/ortion/Desktop/db.sqlite')
return db
def get_detection_hourly(date):
db = get_database()
df = pd.read_sql_query("""SELECT common_name, date, location_id, confidence
FROM observation
INNER JOIN taxon
ON observation.taxon_id = taxon.taxon_id""", db)
df['date'] = pd.to_datetime(df['date'])
df['hour'] = df['date'].dt.hour
df['date'] = df['date'].dt.date
df['date'] = df['date'].astype(str)
df_on_date = df[df['date'] == date]
return df_on_date
def get_top_species(df, limit=10):
return df['common_name'].value_counts()[:CONFIG['readings']]
def get_top_detections(df, limit=10):
df_top_species = get_top_species(df, limit=limit)
return df[df['common_name'].isin(df_top_species.index)]
def get_frequence_order(df, limit=10):
pd.value_counts(df['common_name']).iloc[:limit]
def presence_chart(date, filename):
df_detections = get_detection_hourly(date)
df_top_detections = get_top_detections(df_detections, limit=CONFIG['readings'])
fig, axs = plt.subplots(1, 2, figsize=(15, 4), gridspec_kw=dict(
width_ratios=[3, 6]))
plt.subplots_adjust(left=None, bottom=None, right=None,
top=None, wspace=0, hspace=0)
frequencies_order = get_frequence_order(df_detections, limit=CONFIG["readings"])
# Get min max confidences
confidence_minmax = df_detections.groupby('common_name')['confidence'].max()
# Norm values for color palette
norm = plt.Normalize(confidence_minmax.values.min(),
confidence_minmax.values.max())
colors = plt.cm.Greens(norm(confidence_minmax))
plot = sns.countplot(y='common_name', data=df_top_detections, palette=colors, order=frequencies_order, ax=axs[0])
plot.set(ylabel=None)
plot.set(xlabel="Detections")
heat = pd.crosstab(df_top_detections['common_name'], df_top_detections['hour'])
# Order heatmap Birds by frequency of occurrance
heat.index = pd.CategoricalIndex(heat.index, categories=frequencies_order)
heat.sort_index(level=0, inplace=True)
hours_in_day = pd.Series(data=range(0, 24))
heat_frame = pd.DataFrame(data=0, index=heat.index, columns=hours_in_day)
heat = (heat + heat_frame).fillna(0)
# Generate heatmap plot
plot = sns.heatmap(
heat,
norm=LogNorm(),
annot=True,
annot_kws={
"fontsize": 7
},
fmt="g",
cmap=CONFIG['palette'],
square=False,
cbar=False,
linewidth=0.5,
linecolor="Grey",
ax=axs[1],
yticklabels=False
)
plot.set_xticklabels(plot.get_xticklabels(), rotation=0, size=7)
for _, spine in plot.spines.items():
spine.set_visible(True)
plot.set(ylabel=None)
plot.set(xlabel="Hour of day")
fig.subplots_adjust(top=0.9)
plt.suptitle(f"Top {CONFIG['readings']} species (Updated on {datetime.now().strftime('%Y/%m-%d %H:%M')})")
plt.savefig(filename)
plt.close()
def main():
date = datetime.now().strftime('%Y%m%d')
presence_chart(date, f'./var/charts/chart_{date}.png')
# print(get_top_detections(get_detection_hourly(date), limit=10))
if not db is None:
db.close()
if __name__ == "__main__":
main()

View File

@ -1,15 +0,0 @@
# Launch BirdNET-Analyzer on the previously recorded audio chunks
[Unit]
Description=BirdNET-stream Analyzis
[Service]
User=$USER
Group=$USER
ExecStart=/home/$USER/BirdNET-Analyzer/deamon/birdnet_analysis.sh
Restart=always
RestartSec=3
Type=simple
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,14 @@
[Unit]
Description=BirdNET-stream Analyzis
[Service]
User=<USER>
Group=<GROUP>
WorkingDirectory=<DIR>
ExecStart=bash ./daemon/birdnet_analyzis.sh
Restart=always
RestartSec=3
Type=simple
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,13 @@
[Unit]
Description=BirdNET-stream miner service
[Service]
Type=simple
User=<USER>
Group=<GROUP>
WorkingDirectory=<DIR>
ExecStart=bash ./daemon/birdnet_miner.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,9 @@
[Unit]
Description=BirdNET-stream miner Timer
[Timer]
OnCalendar=*:0/15
Unit=birdnet_miner.service
[Install]
WantedBy=basic.target

View File

@ -0,0 +1,12 @@
[Unit]
Description=BirdNET-stream plotter
[Service]
User=<USER>
Group=<GROUP>
WorkingDirectory=<DIR>
ExecStart=./.venv/birdnet-stream/bin/python3 ./daemon/plotter/chart.py
Type=simple
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,9 @@
[Unit]
Description=BirdNET-stream plotter timer
[Timer]
OnCalendar=*:00
Unit=birdnet_plotter.service
[Install]
WantedBy=basic.target

View File

@ -4,9 +4,10 @@
Description=BirdNET-stream recording
[Service]
User=1000
Group=1000
ExecStart="bash /home/$USER/BirdNET-stream/daemon/birdnet_recording.sh"
User=<USER>
Group=<GROUP>
WorkingDirectory=<DIR>
ExecStart=bash ./daemon/birdnet_recording.sh
Restart=always
RestartSec=3
Type=simple

View File

@ -0,0 +1,12 @@
[Unit]
Description=BirdNET-stream audio streaming service
[Service]
Type=simple
User=<USER>
Group=<GROUP>
WorkingDirectory=<DIR>
ExecStart=bash ./daemon/birdnet_streaming.sh
[Install]
WantedBy=multi-user.target

View File

@ -1,5 +1,5 @@
#! /usr/bin/env bash
set -X
# set -x
set -e
DEBUG=${DEBUG:-0}
@ -8,12 +8,11 @@ DEBUG=${DEBUG:-0}
REQUIREMENTS="git ffmpeg python3-pip python3-dev"
REPOSITORY="https://github.com/UncleSamulus/BirdNET-stream.git"
PYTHON_VENV=".venv/birdnet-stream"
# Update system
update() {
sudo apt-get update
sudo apt-get upgrade
sudo apt-get upgrade -y
}
debug() {
@ -23,7 +22,6 @@ debug() {
}
install_requirements() {
if
requirements=$1
# Install requirements
missing_requirements=""
@ -40,10 +38,16 @@ install_requirements() {
# Install BirdNET-stream
install_birdnetstream() {
# Clone BirdNET-stream
debug "Cloning BirdNET-stream from $REPOSITORY"
git clone --recurse-submodules $REPOSITORY
# Install BirdNET-stream
# Check if repo is not already installed
workdir=$(pwd)
if [ -d "$workdir/BirdNET-stream" ]; then
debug "BirdNET-stream is already installed"
else
# Clone BirdNET-stream
debug "Cloning BirdNET-stream from $REPOSITORY"
git clone --recurse-submodules $REPOSITORY
# Install BirdNET-stream
fi
cd BirdNET-stream
debug "Creating python3 virtual environment '$PYTHON_VENV'"
python3 -m venv $PYTHON_VENV
@ -56,17 +60,94 @@ install_birdnetstream() {
# Install systemd services
install_birdnetstream_services() {
cd BirdNET-stream
DIR=$(pwd)
GROUP=$USER
debug "Setting up BirdNET stream systemd services"
sudo ln -s $PWD/BirdNET-stream/daemon/systemd/birdnet_recording.service /etc/systemd/system/birdnet_recording.service
sudo ln -s $PWD/BirdNET-stream/daemon/systemd/birdnet_analyzis.service /etc/systemd/system/birdnet_analyzis.service
services="birdnet_recording.service birdnet_analyzis.service birdnet_miner.timer birdnet_miner.service birdnet_plotter.service birdnet_plotter.timer"
read -r -a services_array <<<"$services"
for service in ${services_array[@]}; do
sudo cp daemon/systemd/templates/$service /etc/systemd/system/
variables="DIR USER GROUP"
for variable in $variables; do
sudo sed -i "s|<$variable>|${!variable}|g" /etc/systemd/system/$service
done
done
sudo systemctl daemon-reload
sudo systemctl enable --now birdnet_recording.service birdnet_analyzis.service
sudo systemctl enable --now birdnet_recording.service birdnet_analyzis.service birdnet_miner.timer birdnet_plotter.timer
}
install_php8() {
# Remove previously installed php version
sudo apt-get remove --purge php*
# Install required packages for php
sudo apt-get install -y lsb-release ca-certificates apt-transport-https software-properties-common gnupg2
# Get php package from sury repo
echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/sury-php.list
sudo wget -qO - https://packages.sury.org/php/apt.gpg | sudo gpg --no-default-keyring --keyring gnupg-ring:/etc/apt/trusted.gpg.d/debian-php-8.gpg --import
sudo chmod 644 /etc/apt/trusted.gpg.d/debian-php-8.gpg
update
sudo apt-get install php8.1
# Install and enable php-fpm
sudo apt-get install php8.1-fpm
sudo systemctl enable php8.1-fpm
# Install php packages
sudo apt-get install php8.1-{sqlite3,curl,intl}
}
install_composer() {
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"\nphp -r "if (hash_file('sha384', 'composer-setup.php') === '55ce33d7678c5a611085589f1f3ddf8b3c52d662cd01d4ba75c0ee0459970c2200a51f492d557530c71c15d8dba01eae') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"\nphp composer-setup.php\nphp -r "unlink('composer-setup.php');"
sudo mv /composer.phar /usr/local/bin/composer
}
install_nodejs() {
# Install nodejs
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
nvm install 16
nvm use 16
install_requirements "npm"
# Install yarn
sudo npm install -g yarn
}
install_web_interface() {
debug "Setting up web interface"
install_requirements "nginx"
# Install php 8.1
install_php8
# Install composer
install_composer
# Install nodejs 16
install_nodejs
# Install Symfony web app
cd BirdNET-stream
cd www
debug "Creating nginx configuration"
cp nginx.conf /etc/nginx/sites-available/birdnet-stream.conf
sudo mkdir /var/log/nginx/birdnet/
echo "Info: Please edit /etc/nginx/sites-available/birdnet-stream.conf to set the correct server name and paths"
sudo ln -s /etc/nginx/sites-available/birdnet-stream.conf /etc/nginx/sites-enabled/birdnet-stream.conf
sudo systemctl enable --now nginx
sudo systemctl restart nginx
debug "Retrieving composer dependencies"
composer install
debug "Installing nodejs dependencies"
yarn install
debug "Building assets"
yarn build
debug "Web interface is available"
debug "Please restart nginx after double check of /etc/nginx/sites-available/birdnet-stream.conf"
}
main() {
update
install_requirements $REQUIREMENTS
install_birdnetstream
install_birdnetstream_services
install_web_interface
}
main
main

BIN
media/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -3,22 +3,33 @@ audioread==2.1.9
certifi==2022.6.15
cffi==1.15.1
charset-normalizer==2.1.0
cycler==0.11.0
decorator==5.1.1
fonttools==4.34.4
idna==3.3
joblib==1.1.0
kiwisolver==1.4.4
librosa==0.9.2
llvmlite==0.39.0
matplotlib==3.5.3
numba==0.56.0
numpy==1.22.4
packaging==21.3
pandas==1.4.3
Pillow==9.2.0
pooch==1.6.0
pycparser==2.21
pyparsing==3.0.9
python-dateutil==2.8.2
pytz==2022.2.1
requests==2.28.1
resampy==0.3.1
scikit-learn==1.1.2
scipy==1.9.0
seaborn==0.11.2
six==1.16.0
SoundFile==0.10.3.post1
tflite-runtime==2.9.1
threadpoolctl==3.1.0
urllib3==1.26.11
xmpppy==0.7.1

33
www/.env Normal file
View File

@ -0,0 +1,33 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=8bd3643031a08d0cd34e6fd3f680fb22
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8&charset=utf8mb4"
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=14&charset=utf8"
###< doctrine/doctrine-bundle ###
### records folder
RECORDS_DIR=%kernel.project_dir%/../var/chunks # adapt to your needs
###

1
www/.gitignore vendored
View File

@ -1,5 +1,6 @@
###> symfony/framework-bundle ###
!.env
/.env.local
/.env.local.php
/.env.*.local

View File

@ -7,6 +7,31 @@
// any CSS you import will output into a single css file (app.css in this case)
import './styles/app.css';
import './styles/menu.css';
// start the Stimulus application
import './bootstrap';
import feather from 'feather-icons';
feather.replace();
/** Update css variables --{header, footer}-height
* by querying elements real height */
(function() {
let css_root = document.querySelector(':root');
let header = document.getElementsByTagName('header')[0];
let header_height = header.clientHeight;
css_root.style.setProperty('--header-height', header_height + 'px');
let footer = document.getElementsByTagName('footer')[0];
let footer_height = footer.clientHeight;
css_root.style.setProperty('--footer-height', footer_height + 'px');
})();
try {
document.getElementsByClassName('prevent').map(
(e) => e.addEventListener('click', (e) => e.preventDefault())
);
} catch {
console.debug('no prevent class found');
}

View File

@ -0,0 +1,25 @@
import { Controller } from '@hotwired/stimulus';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['filename']
delete() {
let filename = this.filenameTarget.value;
let url = `/records/delete/${filename}`;
fetch(url, {
method: 'POST'
})
.then(response => {
if (response.ok) {
window.location.reload();
} else {
console.log(response);
}
})
.catch(error => {
console.log(error);
}
);
}
}

View File

@ -0,0 +1,25 @@
import { Controller } from '@hotwired/stimulus';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['filename']
delete() {
let filename = this.filenameTarget.value;
let url = `/records/delete/${filename}`;
fetch(url, {
method: 'POST'
})
.then(response => {
if (response.ok) {
window.location.reload();
} else {
console.log(response);
}
})
.catch(error => {
console.log(error);
}
);
}
}

View File

@ -1,31 +1,187 @@
:root {
--bg: white;
--font-family: 'Latin Modern Math';
--font-size: 2em;
}
* {
box-sizing: border-box;
font-family: var(--font-family);
font-size: large;
}
html {
position: relative;
min-height: 100vh;
}
a {
text-decoration: none;
color: inherit;
cursor: pointer;
}
.container {
display: flex;
flex-direction: column;
position: relative;
}
.container.col {
flex-direction: column;
}
.container.row {
flex-direction: row;
}
.grow {
flex-grow: 100;
}
.end {
justify-self: flex-end;
}
.item {
align-self: flex-end;
}
.grid {
display: grid;
}
.grid>* {
padding-left: 0.1em;
}
.behind {
position: relative;
z-index: -1;
}
.above {
position: relative;
z-index: 1;
}
.button, input[type=submit], input[type=button] {
background-color: #f1f1f1;
color: black;
border-radius: 5px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 1em;
cursor: pointer;
appearance: none;
background-color: #FAFBFC;
border: 1px solid rgba(27, 31, 35, 0.15);
border-radius: 6px;
box-shadow: rgba(27, 31, 35, 0.04) 0 1px 0, rgba(255, 255, 255, 0.25) 0 1px 0 inset;
color: #24292E;
cursor: pointer;
display: inline-block;
font-size: 1em;
font-weight: 500;
line-height: 20px;
list-style: none;
padding: 6px 16px;
position: relative;
transition: background-color 0.2s cubic-bezier(0.3, 0, 0.5, 1);
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
vertical-align: middle;
white-space: nowrap;
word-wrap: break-word;
}
.button:hover, input:hover {
background-color: #F3F4F6;
text-decoration: none;
transition-duration: 0.1s;
}
.button:disabled {
background-color: #FAFBFC;
border-color: rgba(27, 31, 35, 0.15);
color: #959DA5;
cursor: default;
}
.button-main:focus:not(:focus-visible):not(.focus-visible) {
box-shadow: none;
outline: none;
}
.button-main:hover {
background-color: #2c974b;
padding: 6px 16px;
}
.button-main:focus {
box-shadow: rgba(46, 164, 79, .4) 0 0 0 3px;
outline: none;
}
.button-main:disabled {
background-color: #94d3a2;
border-color: rgba(27, 31, 35, .1);
color: rgba(255, 255, 255, .8);
cursor: default;
}
.button-main:active {
background-color: #298e46;
box-shadow: rgba(20, 70, 32, .2) 0 1px 0 inset;
}
.button-main:active {
background-color: #EDEFF2;
box-shadow: rgba(225, 228, 232, 0.2) 0 1px 0 inset;
transition: none 0s;
}
.button:focus, input:focus {
outline: 1px transparent;
border: solid blue 1px;
}
body {
background-color: lightgray;
background-color: var(--bg);
margin: 0;
padding: 0;
}
header {
padding: 1em;
text-align: center;
display: flex;
flex-direction: row;
/** Align text and center of image */
justify-content: center;
align-items: baseline;
/* justify-content: center; */
width: fit-content;
margin: auto;
}
header img#logo {
width: 100px;
height: 100px;
header h1 {
font-size: 4rem;
}
header:first-child {
justify-content: center;
}
header img.logo {
width: auto;
height: 10rem;
position: relative;
top: -2rem;
padding-top: 1em;
}
main {
min-height: 80vh;
min-height: calc(100vh - (var(--header-height, 4em) + var(--footer-height, 4em)) );
padding: 5em;
z-index: 0;
}
footer {
@ -33,6 +189,7 @@ footer {
background-color: black;
padding: 2em;
text-align: center;
overflow: hidden;
}
footer a {
@ -44,40 +201,97 @@ footer a:hover {
font-style: italic;
}
nav ul {
list-style-type: none;
display: flex;
flex-direction: row;
justify-content: space-between;
li, td {
align-items: center;
background-color: lightgreen;
margin: 0;
padding: 0;
}
nav ul li a,
.dropdown-button {
color: green;
text-decoration: none;
display: block;
padding: 1em 0.5em;
}
nav ul li a.active,
nav ul li a:hover {
background-color: aquamarine;
/* .dropdown-button:hover {
background-color: #900;
color: white
}
.dropdown:hover .dropdown-content {
display: block;
}
*/
.dropdown-content {
display: none;
position: absolute;
background-color: #f9f9f9;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
z-index: 1;
}
canvas {
display: block;
height: 100%;
width: 100%;
}
.scientific-name {
font-style: italic;
}
.dataviz img {
max-width: 100%;
width: 100%;
}
#statuses li {
list-style: none;
}
#statuses .grid {
display: grid;
grid-template-rows: 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
gap: 0.1em;
}
.status.bullet {
border-radius: 50%;
background-color: #999;
width: 1em;
height: 1em;
display: inline-block;
position: relative;
top: 0.4em;
z-index: -1;
}
.status.inactive.bullet {
background-color: #999;
}
.status.active.bullet {
background-color: #090;
}
.status.dead.bullet {
background-color: #900;
}
.overlay {
z-index: 10;
opacity: 100%;
background-color: rgba(255, 255, 255, 1);
position: relative;
}
.logs {
background-color: black;
color: white;
font: monospace;
padding: 1em;
overflow-y: scroll;
height: 100%;
}
@media screen and (max-width: 700px) {
main {
padding: 1em;
}
}

154
www/assets/styles/menu.css Normal file
View File

@ -0,0 +1,154 @@
nav {
--nav-width: 20em;
--nav-bg: lightgrey;
--burger-size: 5em;
--burger-weight: 3px;
position: fixed;
top: 0;
left: 0;
width: 0;
z-index: 10;
}
.toggler {
z-index: 21;
height: var(--burger-size);
width: var(--burger-size);
align-items: center;
justify-content: center;
padding: 0.6rem;
position: fixed;
cursor: pointer;
opacity: 0;
}
.hamburger {
position: fixed;
top: 0;
left: 0;
height: var(--burger-size);
width: var(--burger-size);
padding: 0.6rem;
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
opacity: 1;
}
.hamburger>div {
position: relative;
top: 0;
left: 0;
background: black;
height: var(--burger-weight);
width: 75%;
transition: all 0.4s ease;
color: #000;
}
.hamburger>div::before,
.hamburger>div::after {
content: '';
position: absolute;
top: 10px;
background: black;
width: 100%;
height: var(--burger-weight);
transition: all 0.4s ease;
}
.hamburger>div::after {
top: -10px;
}
.toggler:checked+.hamburger>div {
background-color: var(--nav-bg);
}
.toggler:checked+.hamburger>div::before {
top: 0;
transform: rotate(45deg);
background: black;
}
.toggler:checked+.hamburger>div::after {
top: 0;
transform: rotate(135deg);
background: black;
}
.menu {
height: 100vh;
width: 0;
visibility: hidden;
}
.toggler:checked~.menu {
width: fit-content;
height: fit-content;
background-color: var(--nav-bg) !important;
}
.menu>ul {
display: flex;
flex-direction: column;
position: fixed;
width: 0;
height: calc(100vh - var(--burger-size));
padding-left: 1em;
padding-right: 1em;
margin-top: var(--burger-size);
visibility: hidden;
background-color: var(--nav-bg);
z-index: 10;
}
.menu>ul>li {
list-style: none;
padding: 0.5rem;
}
.menu>ul>li>a {
color: black;
text-decoration: none;
font-size: var(--font-size);
}
.menu>ul>li>a:hover, .menu>ul>li>a.active {
color: orange;
}
.toggler~.fill {
visibility: hidden;
width: 0;
}
.toggler:checked~.fill {
background-color: var(--nav-bg);
position: fixed;
top: 0;
left: 0;
height: var(--burger-size);
width: var(--nav-width);
z-index: 10;
transition: visibility .5s, width .5s;
visibility: visible;
}
.toggler:checked~.menu>ul {
width: var(--nav-width);
opacity: 1;
transition: visibility .5s, width .5s;
visibility: visible;
}
@media screen and (max-width: 700px) {
nav {
--burger-size: 4em;
}
}

34
www/assets/utils/date.js Normal file
View File

@ -0,0 +1,34 @@
let date_input;
let endpoint;
try {
date_input = document.querySelector(".date-selector input[type='date']");
let next_date_button = document.getElementsByClassName("next-date-button")[0];
let previous_date_button = document.getElementsByClassName("previous-date-button")[0];
endpoint = document.querySelector(".date-selector a").href.split("/")[3];
next_date_button.addEventListener("click", next_date);
previous_date_button.addEventListener("click", previous_date);
} catch {
console.debug("no date input found");
}
function next_date() {
let date = new Date(date_input.value);
date.setDate(date.getDate() + 1);
date_input.value = date.toISOString().split('T')[0];
update_date_link();
}
function previous_date() {
let date = new Date(date_input.value);
date.setDate(date.getDate() - 1);
date_input.value = date.toISOString().split('T')[0];
update_date_link();
}
function update_date_link() {
let date = new Date(date_input.value);
let date_link = document.querySelector(".date-selector a");
date_link.href = `/${endpoint}/${date.toISOString().split('T')[0]}/`;
}

View File

@ -0,0 +1,83 @@
/**
* Credits to:
* https://codepen.io/jakealbaugh/pen/jvQweW
*/
const ICECAST_URL = '/stream';
var started = false;
try {
var spectro_button = document.getElementById('spectro-button');
spectro_button.addEventListener('click', () => {
if (started) return;
started = true;
console.log("starting spectro");
initialize();
})
} catch {
console.debug("spectro not found");
}
function initialize() {
const AUDIO_ELEMENT = document.getElementById('player');
const CVS = document.getElementById('spectro-canvas');
const CTX = CVS.getContext('2d');
const W = CVS.width = window.innerWidth;
const H = CVS.height = window.innerHeight;
const ACTX = new AudioContext();
const ANALYSER = ACTX.createAnalyser();
ANALYSER.fftSize = 4096;
// navigator.mediaDevices
// .getUserMedia({ audio: true })
// .then(process);
// Add icecast stream
// var audio = new Audio(ICECAST_URL);
let stream;
AUDIO_ELEMENT.src = ICECAST_URL;
AUDIO_ELEMENT.play();
AUDIO_ELEMENT.onplay = function () {
if (navigator.userAgent.indexOf('Firefox') > -1) {
stream = AUDIO_ELEMENT.mozCaptureStream();
} else {
console.debug('Not a firefox browser, defaults to `captureStream()`');
stream = AUDIO_ELEMENT.captureStream();
}
process(AUDIO_ELEMENT);
}
function process(audio) {
const SOURCE = ACTX.createMediaElementSource(audio);
SOURCE.connect(ANALYSER);
const DATA = new Uint8Array(ANALYSER.frequencyBinCount);
const LEN = DATA.length;
const h = H / LEN;
const x = W - 1;
CTX.fillStyle = 'hsl(280, 100%, 10%)';
CTX.fillRect(0, 0, W, H);
loop();
function loop() {
window.requestAnimationFrame(loop);
let imgData = CTX.getImageData(1, 0, W - 1, H);
CTX.fillRect(0, 0, W, H);
CTX.putImageData(imgData, 0, 0);
ANALYSER.getByteFrequencyData(DATA);
for (let i = 0; i < LEN; i++) {
let rat = DATA[i] / 255;
let hue = Math.round((rat * 120) + 280 % 360);
let sat = '100%';
let lit = 10 + (70 * rat) + '%';
CTX.beginPath();
CTX.strokeStyle = `hsl(${hue}, ${sat}, ${lit})`;
CTX.moveTo(x, H - (i * h));
CTX.lineTo(x, H - (i * h + h));
CTX.stroke();
}
}
}
}

View File

@ -7,17 +7,22 @@
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "^2.7",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^2.13",
"sensio/framework-extra-bundle": "^6.2",
"symfony/console": "6.1.*",
"symfony/dotenv": "6.1.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "6.1.*",
"symfony/proxy-manager-bridge": "6.1.*",
"symfony/runtime": "6.1.*",
"symfony/translation": "6.1.*",
"symfony/twig-bundle": "6.1.*",
"symfony/webpack-encore-bundle": "^1.15",
"symfony/yaml": "6.1.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/intl-extra": "^3.4",
"twig/twig": "^2.12|^3.0"
},
"config": {
@ -71,5 +76,8 @@
"allow-contrib": false,
"require": "6.1.*"
}
},
"require-dev": {
"symfony/maker-bundle": "^1.45"
}
}

1912
www/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,4 +6,7 @@ return [
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
];

View File

@ -0,0 +1,42 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '13'
orm:
auto_generate_proxy_classes: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: '%kernel.debug%'

View File

@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
app.records_dir: '%env(resolve:RECORDS_DIR)%'
services:
# default configuration for services in *this* file

View File

@ -0,0 +1,8 @@
version: '3'
services:
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
###< doctrine/doctrine-bundle ###

21
www/docker-compose.yml Normal file
View File

@ -0,0 +1,21 @@
version: '3'
services:
###> doctrine/doctrine-bundle ###
database:
image: postgres:${POSTGRES_VERSION:-14}-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
# You should definitely change the password in production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
POSTGRES_USER: ${POSTGRES_USER:-app}
volumes:
- db-data:/var/lib/postgresql/data:rw
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ###
volumes:
###> doctrine/doctrine-bundle ###
db-data:
###< doctrine/doctrine-bundle ###

0
www/migrations/.gitignore vendored Normal file
View File

34
www/nginx.conf Normal file
View File

@ -0,0 +1,34 @@
server {
listen 80;
server_name birdnet.example.com;
root /var/www/html;
location / {
return 302 https://$server_name$request_uri;
}
}
server {
listen 443 ssl;
server_name birdnet.example.com;
root /var/www/html;
ssl on;
ssl_certificate /etc/nginx/ssl/birdnet.crt;
ssl_certificate_key /etc/nginx/ssl/birdnet.key;
index index.html index.htm index.php;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php-fpm/www.sock;
fastcgi_index index.php;
include fastcgi.conf;
}
access_log /var/log/nginx/birdnet/birdnet-access.log;
error_log /var/log/nginx/birdnet/birdnet-error.log error;
}

View File

@ -14,5 +14,8 @@
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production --progress"
},
"dependencies": {
"feather-icons": "^4.29.0"
}
}

BIN
www/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1,31 @@
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Doctrine\DBAL\Connection;
class AuthController extends AbstractController
{
private Connection $connection;
/**
* @Route("/auth", name="auth")
*/
public function index(Connection $connection)
{
return $this->redirectToRoute("login");
}
/**
* @Route("/auth/login", name="login")
*/
public function login()
{
return $this->render('auth/login.html.twig', [
]);
}
}

View File

@ -1,21 +1,80 @@
<?php
// src/Controller/HomeController.php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Doctrine\DBAL\Connection;
class HomeController extends AbstractController
{
private Connection $connection;
/**
* @Route("/", name="home")
*/
public function index()
public function index(Connection $connection)
{
$this->connection = $connection;
return $this->render('index.html.twig', [
"stats" => $this->get_stats(),
"charts" => $this->last_chart_generated(),
]);
}
/**
* @Route("/about", name="about")
*/
public function about()
{
return $this->render('about/index.html.twig', [
]);
}
private function get_stats()
{
$stats = array();
$stats["most-recorded-species"] = $this->get_most_recorded_species();
$stats["last-detected-species"] = $this->get_last_recorded_species();
return $stats;
}
private function get_most_recorded_species()
{
$sql = "SELECT `scientific_name`, `common_name`, COUNT(*) AS contact_count
FROM `taxon`
INNER JOIN `observation`
ON `taxon`.`taxon_id` = `observation`.`taxon_id`
ORDER BY `contact_count` DESC LIMIT 1";
$stmt = $this->connection->prepare($sql);
$result = $stmt->executeQuery();
$species = $result->fetchAllAssociative();
return $species[0];
}
private function get_last_recorded_species()
{
$sql = "SELECT `scientific_name`, `common_name`, `date`, `audio_file`, `confidence`
FROM `observation`
INNER JOIN `taxon`
ON `observation`.`taxon_id` = `taxon`.`taxon_id`
ORDER BY `date` DESC LIMIT 1";
$stmt = $this->connection->prepare($sql);
$result = $stmt->executeQuery();
$species = $result->fetchAllAssociative();
return $species[0];
}
private function last_chart_generated() {
$files = glob($this->getParameter('kernel.project_dir') . '/../var/charts/*.png');
usort($files, function($a, $b) {
return filemtime($b) - filemtime($a);
});
$last_chart = basename(array_pop($files));
return $last_chart;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class LogsController extends AbstractController
{
private $allowed_services = "recording analyzis miner plotter";
/**
* @Route("/logs/{service}", name="logs")
*/
public function logs($service = "all")
{
$logs = "";
if ($service === "all") {
foreach (explode(" ", $this->allowed_services) as $service) {
$logs .= $this->journal_logs($service);
}
} else if (str_contains($this->allowed_services, $service)) {
$logs .= $this->journal_logs($service);
} else {
return new Response("Service not found", Response::HTTP_BAD_REQUEST);
}
return $this->render('logs/logs.html.twig', [
'logs' => $logs
]);
}
private function journal_logs($service)
{
$logs = shell_exec("journalctl -u birdnet_recording -n 10");
return $logs;
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Doctrine\DBAL\Connection;
class RecordsController extends AbstractController
{
private Connection $connection;
/**
* @Route("/records/{date}", name="records_index")
*/
public function records_index($date = "now")
{
if ($date == "now") {
$date = date("Y-m-d");
}
$records = $this->list_records();
$records = $this->only_on($date, $records);
return $this->render('records/index.html.twig', [
'records' => $records,
'date' => $date,
]);
}
/**
* @Route("/records/delete/{basename}", name="record_delete")
*/
public function delete_record(Connection $connection, $basename)
{
$this->connection = $connection;
$this->remove_record_by_basename($basename);
return $this->redirectToRoute('records_index');
}
private function list_records()
{
$records_path = $this->getParameter('app.records_dir') . "/out/*.wav";
$records = glob($records_path);
$records = array_map(function ($record) {
$record = basename($record);
return $record;
}, $records);
return $records;
}
private function get_record_date($record_path)
{
$record_basename = basename($record_path);
$record_date = explode("_", explode(".", $record_basename)[0])[1];
$year = substr($record_date, 0, 4);
$month = substr($record_date, 4, 2);
$day = substr($record_date, 6, 2);
$date = "$year-$month-$day";
return $date;
}
private function only_on($date, $records)
{
$filtered_records = array_filter($records, function ($record) use ($date) {
return $this->get_record_date($record) == $date;
});
return $filtered_records;
}
private function remove_record_by_basename($basename)
{
if (strlen($basename) > 1) {
/** Remove files associated with this filename */
$record_path = $this->getParameter('app.records_dir') . "/out/$basename";
if (is_file($record_path))
unlink($record_path);
$model_out_dir = $record_path.".d";
$model_out_path = $model_out_dir."/model.out.csv";
if (is_file($model_out_path))
unlink($model_out_path);
if (is_dir($model_out_dir))
rmdir($model_out_dir);
/** Remove database entry associated with this filename */
$this->remove_observations_from_record($basename);
}
}
private function remove_observations_from_record($basename)
{
$sql = "DELETE FROM observation WHERE audio_file = :filename";
$stmt = $this->connection->prepare($sql);
$stmt->bindValue(':filename', $basename);
$stmt->executeStatement();
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class ServicesController extends AbstractController
{
private $services_available = array("recording", "analyzis", "miner", "plotter");
private $allowed_actions = array("start", "stop");
/**
* @Route("/services/status", name="service_status")
*/
public function service_status() {
$status = array_map(function($service) {
return array(
"name" => $service,
"status" => $this->systemd_service_status($service)
);
}, $this->services_available);
return $this->render('services/status.html.twig', [
'status' => $status
]);
}
/**
* @Route("/services/manage/{action}/{service}", name="service_manager")
*/
public function service_manage($action, $service="all")
{
$error = "";
if (in_array($action, $this->allowed_actions)) {
if ($service == "all") {
foreach ($this->services_available as $service) {
if(($output = $this->manage_systemd_service($action, $service)) != "true") {
$error .= "Error while managing $service service";
dump($output);
}
}
} else if (in_array($service, $this->services_available)) {
if(($output = $this->manage_systemd_service($action, $service)) != "true") {
$error .= "Error while managing $service service";
dump($output);
}
} else {
$error .= "Service not found";
}
} else {
$error .= "Action not allowed";
}
if ($error != "") {
return new Response($error, Response::HTTP_BAD_REQUEST);
} else {
return new Response("OK", Response::HTTP_OK);
}
}
private function manage_systemd_service($action, $service)
{
// TODO correct this command (failed with not root user)
$command = "./daemon/birdnet_manager.sh $action birdnet_$service";
$old_path = getcwd();
$workdir = $this->getParameter("kernel.project_dir");
chdir($workdir);
$output = shell_exec($command);
dump($output);
chdir($old_path);
return $output;
}
private function systemd_service_status($service)
{
$status = array();
$command = "systemctl is-active birdnet_".$service.".service";
$output = shell_exec($command);
if (! is_null($output))
$status["status"] = $output;
else
$status["status"] = "unknown";
$command = "systemctl is-enabled birdnet_".$service.".service";
$output = shell_exec($command);
if (! is_null($output))
$status["enabled"] = $output;
else
$status["enabled"] = "unknown";
$status["eta"] = $this->systemd_timer_eta($service);
return $status;
}
private function systemd_timer_eta($service)
{
$eta = "";
$command = "systemctl list-timers | grep $service.timer | cut -d' ' -f5";
$output = shell_exec($command);
// dump($output);
if (! is_null($output))
$eta = $output;
else
$eta = "na";
return $eta;
}
}

View File

@ -1,5 +1,4 @@
<?php
// src/Controller/AboutController.php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
@ -7,14 +6,14 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class AboutController extends AbstractController
class SpectroController extends AbstractController
{
/**
* @Route("/about", name="about")
* @Route("/spectro", name="spectro")
*/
public function about()
{
return $this->render('about/index.html.twig', [
return $this->render('spectro/index.html.twig', [
]);
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Doctrine\DBAL\Connection;
class TodayController extends AbstractController
{
private Connection $connection;
/**
* @Route("/today", name="today")
*/
public function today(Connection $connection)
{
return $this->redirectToRoute("today_species");
}
/**
* @Route("/today/species", name="today_species")
*/
public function today_species_page(Connection $connection)
{
$this->connection = $connection;
$date = date('Y-m-d');
return $this->render('today/index.html.twig', [
"date" => $date,
"results" => $this->recorded_species_by_date($date),
]);
}
/**
* @Route("/today/species/{id}", name="today_species_id")
*/
public function today_species_by_id(Connection $connection, $id)
{
$this->connection = $connection;
$date = date('Y-m-d');
return $this->render('today/species.html.twig', [
"date" => $date,
"results" => $this->recorded_species_by_id_and_date($id, $date)
]);
}
/**
* @Route("/today/{date}", name="today_date")
*/
public function today_date(Connection $connection, $date)
{
return $this->redirectToRoute('today_species_date', array('date' => $date));
}
/**
* @Route("/today/{date}/species", name="today_species_date")
*/
public function today_species_by_date(Connection $connection, $date)
{
$this->connection = $connection;
return $this->render('today/index.html.twig', [
"date" => $date,
"results" => $this->recorded_species_by_date($date)
]);
}
/**
* @Route("/today/{date}/species/{id}", name="today_species_id_and_date")
*/
public function today_species_by_id_and_date(Connection $connection, $date, $id)
{
$this->connection = $connection;
return $this->render('today/species.html.twig', [
"date" => $date,
"results" => $this->recorded_species_by_id_and_date($id, $date)
]);
}
private function recorded_species_by_date($date)
{
$sql = "SELECT `taxon`.`taxon_id`, `scientific_name`, `common_name`, COUNT(*) AS `contact_count`, MAX(`confidence`) AS max_confidence
FROM observation
INNER JOIN taxon
ON observation.taxon_id = taxon.taxon_id
WHERE strftime('%Y-%m-%d', `observation`.`date`) = :date
GROUP BY observation.taxon_id";
$stmt = $this->connection->prepare($sql);
$stmt->bindValue(':date', $date);
$result = $stmt->executeQuery();
return $result->fetchAllAssociative();
}
private function recorded_species_by_id_and_date($id, $date)
{
/* Get taxon even if there is no record this date */
$sql = "SELECT * FROM `taxon` WHERE `taxon_id` = :id";
$stmt = $this->connection->prepare($sql);
$stmt->bindValue(':id', $id);
$result = $stmt->executeQuery();
$taxon = $result->fetchAllAssociative()[0];
if (!$taxon) {
return [];
}
/* Get daily stats */
$sql = "SELECT COUNT(*) AS `contact_count`, MAX(`confidence`) AS `max_confidence`
FROM `taxon`
INNER JOIN `observation`
ON `taxon`.`taxon_id` = `observation`.`taxon_id`
WHERE strftime('%Y-%m-%d', `observation`.`date`) = :date
AND `observation`.`taxon_id` = :id";
$stmt = $this->connection->prepare($sql);
$stmt->bindValue(':id', $id);
$stmt->bindValue(':date', $date);
$result = $stmt->executeQuery();
$stat = $result->fetchAllAssociative();
$sql = "SELECT * FROM `observation`
WHERE `taxon_id` = :id
AND strftime('%Y-%m-%d', `observation`.`date`) = :date
ORDER BY `observation`.`date` ASC";
$stmt = $this->connection->prepare($sql);
$stmt->bindValue(':id', $id);
$stmt->bindValue(':date', $date);
$result = $stmt->executeQuery();
$records = $result->fetchAllAssociative();
return array("taxon" => $taxon, "stat" => $stat, "records" => $records);
}
private function best_confidence_today($id, $date)
{
$sql = "SELECT MAX(`confidence`) AS confidence
FROM `observation`
WHERE strftime('%Y-%m-%d', `observation`.`date`) = :date
AND `taxon_id` = :id";
$stmt = $this->connection->prepare($sql);
$stmt->bindValue(':id', $id);
$stmt->bindValue(':date', $date);
$result = $stmt->executeQuery();
return $result->fetchAllAssociative();
}
}

0
www/src/Entity/.gitignore vendored Normal file
View File

0
www/src/Repository/.gitignore vendored Normal file
View File

View File

@ -8,6 +8,33 @@
"ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05"
}
},
"doctrine/doctrine-bundle": {
"version": "2.7",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "da713d006953b90d1c085c1be480ecdd6c4a95e0"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "ee609429c9ee23e22d6fa5728211768f51ed2818"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"sensio/framework-extra-bundle": {
"version": "6.2",
"recipe": {
@ -63,6 +90,15 @@
"src/Kernel.php"
]
},
"symfony/maker-bundle": {
"version": "1.45",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/routing": {
"version": "6.1",
"recipe": {

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>
@ -16,14 +16,17 @@
{{ encore_entry_script_tags('app') }}
{% endblock %}
</head>
<body>
<body class="container col">
{% block body %}
<header>
<img id="logo" src="/media/logo.svg" alt="BirdNET-stream logo">
<h1>BirdNET-stream</h1>
</header>
{% include "menu.html.twig" %}
<header>
<a href="/">
<div class="container row">
<img class="logo" src="/media/logo.svg" alt="BirdNET logo">
<h1>BirdNET-stream</h1>
</div>
</a>
</header>
<main>
{% block content %}
<p>Welcome to BirdNET-stream !</p>

View File

@ -0,0 +1,7 @@
<div class="dataviz">
{% if charts is defined and charts | length > 0 %}
<img src="/media/charts/{{ charts }}" alt="{{ "Frequency charts" | trans }}">
{% else %}
<p>{{ "No charts available" | trans }}</p>
{% endif %}
</div>

View File

@ -1,4 +1,4 @@
<footer>
<footer class="end">
<p>
&copy; <span class="creation-date">2022
<!-- check if the current year is the same as the creation year -->

View File

@ -1,5 +1,7 @@
{% extends "base.html.twig" %}
{% block content %}
<p>Welcome to BirdNET-stream !</p>
<p>{{ "Welcome to BirdNET-stream !" | trans }}</p>
{% include "stats.html.twig" %}
{% include "chart.html.twig" %}
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base.html.twig" %}
{% block content %}
<h2>
{{ "Logs" | trans }}
</h2>
{% if logs is defined and logs | length > 0 %}
<pre class="logs">
{{ logs }}
</pre>
{% else %}
<p>{{ "No logs available" | trans }}</p>
{% endif %}
{% endblock %}

View File

@ -1,40 +1,66 @@
<nav class="nav-bar">
<nav class="navbar">
<input type="checkbox" class="toggler" />
<div class="hamburger">
<div></div>
</div>
<div class="fill"></div>
<div class="menu overlay">
<ul>
<li>
<a href="/">Home</a>
</li>
<li>
<a href="/about">About</a>
</li>
<li>
<a href="/today">Today's Detections</a>
</li>
<li>
<a href="/spectro">Spectrogram</a>
</li>
<li>
<a href="/stats">Species Stats</a>
</li>
<li class="dropdown">
<a href="/records" class="dropdown-button">Recordings</a>
<ul class="dropdown-content">
<li>
<a href="/records/bests">
Best Recordings
</a>
</li>
</ul>
</li>
<li>
<a href="/charts">Daily Charts</a>
</li>
<li>
<a href="/logs">View Logs</a>
</li>
<li>
<a href="/tools">
Tools
</a>
</li>
{% include 'utils/nav-item.html.twig' with {
route: 'home',
url: '/',
name: 'Home'|trans
} %}
{% include 'utils/nav-item.html.twig' with {
route: 'about',
url: '/about',
name: 'About'|trans
} %}
{% include 'utils/nav-item.html.twig' with {
route: 'today',
url: '/today',
name: "Today's Detections"|trans
} %}
{% include 'utils/nav-item.html.twig' with {
route: 'spectro',
url: '/spectro',
name: 'Live Spectrogram'|trans
} %}
{% include 'utils/nav-item.html.twig' with {
route: 'stats',
url: '/stats',
name: 'Statistics'|trans
} %}
<li class="dropdown">
{% include 'utils/nav-item.html.twig' with {
route: 'records',
url: '/records',
name: 'Recordings'|trans
} %}
<ul class="dropdown-content">
{% include 'utils/nav-item.html.twig' with {
route: 'bests',
url: '/records/best',
name: 'Best Recordings'|trans
} %}
</ul>
</li>
{% include 'utils/nav-item.html.twig' with {
route: 'bests',
url: '/records/best',
name: 'Best Recordings'|trans
} %}
{% include 'utils/nav-item.html.twig' with {
route: 'logs',
url: '/logs',
name: 'View Logs'|trans
} %}
{% include 'utils/nav-item.html.twig' with {
route: 'status',
url: '/services/status',
name: 'Status'|trans
} %}
</ul>
</nav>
</div>
</nav>

View File

@ -0,0 +1,5 @@
<div {{ stimulus_controller('delete-record') }}>
<button class="delete button" value="{{ filename }}" {{ stimulus_target('delete-record', 'filename') }} {{ stimulus_action('delete-record', 'delete') }}>
<i data-feather="trash-2"></i>
</button>
</div>

View File

@ -0,0 +1,18 @@
{% extends "base.html.twig" %}
{% block content %}
<h2>{{ "Records" }}</h2>
{% set endpoint = "records" %}
{% include "utils/calendar.html.twig" %}
{% if records is defined and records | length > 0 %}
<ul>
{% for record in records %}
<li>{{ record }}
{% include "records/delete_button.html.twig" with { filename: record } only %}
{% include "records/player.html.twig" with { filename: record } only %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,3 @@
<audio preload="none" controls>
<source src="/media/records/{{ filename }}">
</audio>

View File

@ -0,0 +1,22 @@
{% extends "base.html.twig" %}
{% block content %}
<h2>
{{ "Services status" | trans }}
</h2>
{% if status is defined and status | length > 0 %}
<ul id="statuses" class="container column">
{% for service in status %}
<li class="grid">
<div class="col status bullet {{ service.status.status }}"></div>
<div class="col name">{{ service.name }}</div>
<div class="col status">{{ service.status.status }}</div>
<div class="col status">{{ service.status.enabled }}</div>
<div class="col eta">{{ service.status.eta }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ "No status available" | trans }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "base.html.twig" %}
{% block content %}
<h2>{{ "Spectrogram" | trans }}</h2>
<div>
<button id="spectro-button">{{ "Launch Live Spectrogram" | trans }}</button>
<audio id="player" controls>
<source media="/stream">
{{ "No audio sources yet" | trans }}
</audio>
<canvas id="spectro-canvas"></canvas>
</div>
{{ encore_entry_script_tags('spectro') }}
{% endblock %}

View File

@ -0,0 +1,50 @@
<div id="stats">
<h2>{{ "Quick Stats" | trans }}</h2>
<ul>
<li class="most-recorded-species">
{{ "Most recorded species" | trans }}:
{% if stats["most-recorded-species"] is defined %}
<span class="scientific-name">
{{ stats["most-recorded-species"]["scientific_name"] }}
</span>
(<span class="common_name">{{ stats["most-recorded-species"]["common_name"] }}</span>)
{{ "with" | trans }}
<span class="observation-count">
{{ stats["most-recorded-species"]["contact_count"] }}
</span>
{{ "contacts" | trans }}.
{% else %}
{{ "No species in database." | trans }}
{% endif %}
</li>
<li class="last-recorded-species">
{{ "Last detected species" | trans }}:
{% if stats["last-detected-species"] is defined %}
<span class="scientific-name">
{{ stats["last-detected-species"]["scientific_name"] }}
</span>
(<span class="common_name">{{ stats["last-detected-species"]["common_name"] }}</span>)
{{ "with" | trans }}
<span class="confidence">
{{ stats["last-detected-species"]["confidence"] }}
</span>
{{ "AI confidence" | trans }}
<span class="datetime">
{% set date = stats["last-detected-species"]["date"] %}
{% if date | date("Y-m-d") == "now" | date("Y-m-d") %}
{{ "today" | trans }}
{% else %}
{{ "on" | trans }}
{{ date | format_datetime("full", "none") }}
{% endif %}
at
<span class="time">
{{ date | date("H:i") }}
</span>
</span>.
{% else %}
{{ "No species in database" | trans }}
{% endif %}
</li>
</ul>
</div>

View File

@ -0,0 +1,2 @@
<img class="logo" src="/media/logo.svg" alt="BirdNET-stream logo">
<h1>BirdNET-stream</h1>

View File

@ -0,0 +1,31 @@
{% extends "base.html.twig" %}
{% block content %}
<h2>
{% set today = "now" | date("Y-m-d") %}
{% if today == date %}
{{ "Today's detected species" | trans }}
{% else %}
{{ "Detected species on" | trans }}
{{ date | format_datetime("full", "none") }}
{% endif %}
</h2>
{% set endpoint = "today" %}
{% include "utils/calendar.html.twig" %}
{# Display a list of records if any, else, print message #}
{% if results is defined and results | length > 0 %}
<ul>
{% for sp in results %}
<li class="species">
<a href="./species/{{ sp['taxon_id'] }}">
<span class="scientific-name">{{ sp["scientific_name"] }}
(</span>
<span class="common-name">{{ sp["common_name"] }}</span>)
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ "No species detected this day" | trans }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,8 @@
<div class="manager">
<button class="button" id="select-all" title="{{ "Select all" | trans }}">
<i data-feather="check-square"></i>
</button>
<button class="delete button" title="{{ "Delete selected" | trans }}">
<i data-feather="trash-2"></i>
</button>
</div>

View File

@ -0,0 +1,74 @@
{% extends "base.html.twig" %}
{% block content %}
{% if results is defined and results | length > 0 %}
{% set taxon = results.taxon %}
<h2>
{% set today = "now" | date("Y-m-d") %}
{% if today == date %}
{{ "Today's contacts for" | trans }}
{% else %}
{{ "Contacts on " | trans }}
{{ date | format_datetime("full", "none") }}
{{ " for " | trans }}
{% endif %}
<span class="scientific-name">
{{ taxon.scientific_name }}
</span>
(<span class="common-name">
{{ taxon.common_name }}
</span>)
</h2>
<div class="stats">
{% set stat = results.stat[0] %}
<h3>{{ "Stats" | trans }}</h3>
<div class="contact-count">
{{ "Contact count:" | trans }}
<span class="counter">{{ stat.contact_count }}</span>
</div>
<div class="contact-confidence">
{{ "Max confidence" | trans }}
<span class="value">{{ stat.max_confidence }}</span>
</div>
</div>
{% set records = results.records %}
<div class="records">
<h3>{{ "Contact records" | trans }}</h3>
{% if records is defined and records | length > 0 %}
{% include "today/manage.html.twig" %}
<table>
<thead>
<tr>
<th></th>
<th>{{ "Filename" | trans }}</th>
<th>{{ "Time" | trans }}</th>
<th>{{ "Confidence" | trans }}</th>
<th>{{ "Audio" | trans }}</th>
</thead>
<tbody>
{% for record in records %}
<tr>
<td>
<input title="{{ "Select this record" | trans }}" type="checkbox" name="select-file" value="{{ record.audio_file }}">
</td>
<td>
<a title="{{ "Download audio file" | trans }}" href="/media/records/{{ record.audio_file }}">
{{ record.audio_file }}
</a>
</td>
<td>{{ record.date | date("H:m") }}</td>
<td>{{ record.confidence }}</td>
<td>
{% include "records/player.html.twig" with { "filename": record.audio_file } only %}
{% include "records/delete_button.html.twig" with { "filename": record.audio_file } only %} </td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>{{ "No records this day for this species" | trans }}</p>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,9 @@
<div class="date-selector container row">
<label for="date">{{ "Date" | trans }}</label>
<button class="previous-date-button button">&lt;</button>
<input type="date" id="date" name="date" value="{{ date ? date : "now" | date("Y-m-d") }}" required title="{{ "Enter a date in this format YYYY-MM-DD" | trans }}"/>
<button class="next-date-button button prevent">&gt;</button>
<a href="/{{ endpoint }}/{{ date ? date : " now" | date("Y-m-d") }}/" class="button main">{{ "Go" | trans }}</a>
</div>
{{ encore_entry_script_tags('date') }}

View File

@ -0,0 +1,6 @@
<li>
<a class="{{ app.request.get('_route') matches '{' ~ route ~ '_*}' ? 'active' }}"
href="{{ url }}">
{{ name }}
</a>
</li>

View File

@ -22,6 +22,9 @@ Encore
*/
.addEntry('app', './assets/app.js')
.addEntry('spectro', './assets/utils/spectro.js')
.addEntry('date', './assets/utils/date.js')
// enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js)
.enableStimulusBridge('./assets/controllers.json')

View File

@ -1660,6 +1660,11 @@ chrome-trace-event@^1.0.2:
resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac"
integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==
classnames@^2.2.5:
version "2.3.1"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
clean-webpack-plugin@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz#72947d4403d452f38ed61a9ff0ada8122aacd729"
@ -1797,7 +1802,7 @@ core-js-compat@^3.21.0, core-js-compat@^3.22.1:
browserslist "^4.21.3"
semver "7.0.0"
core-js@^3.23.0:
core-js@^3.1.3, core-js@^3.23.0:
version "3.24.1"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.24.1.tgz#cf7724d41724154010a6576b7b57d94c5d66e64f"
integrity sha512-0QTBSYSUZ6Gq21utGzkfITDylE8jWC9Ne1D2MrhvlsZBI1x39OdDIVbzSqtgMndIy6BlHxBXpMGqzZmnztg2rg==
@ -2249,6 +2254,14 @@ faye-websocket@^0.11.3:
dependencies:
websocket-driver ">=0.5.1"
feather-icons@^4.29.0:
version "4.29.0"
resolved "https://registry.yarnpkg.com/feather-icons/-/feather-icons-4.29.0.tgz#4e40e3cbb7bf359ffbbf700edbebdde4e4a74ab6"
integrity sha512-Y7VqN9FYb8KdaSF0qD1081HCkm0v4Eq/fpfQYQnubpqi0hXx14k+gF9iqtRys1SIcTEi97xDi/fmsPFZ8xo0GQ==
dependencies:
classnames "^2.2.5"
core-js "^3.1.3"
fill-range@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"