Compare commits
22 Commits
601b402db5
...
2516efa805
Author | SHA1 | Date |
---|---|---|
Samuel Ortion | 2516efa805 | |
Samuel Ortion | ca14913aaa | |
Samuel Ortion | 6d89918c6b | |
Samuel Ortion | 3ac15ba849 | |
Samuel Ortion | c92d8c53ef | |
Samuel Ortion | e74139fe72 | |
Samuel Ortion | 32a2b92f14 | |
Samuel Ortion | b90a0cd63a | |
Samuel Ortion | 4e6d4f396b | |
Samuel Ortion | c0728d2969 | |
Samuel Ortion | acc51fdfc4 | |
Samuel Ortion | d56f3806fa | |
Samuel Ortion | e44b8542b0 | |
Samuel Ortion | bd35b4c496 | |
Samuel Ortion | 8f5388c6f2 | |
Samuel Ortion | 128e3f33bb | |
Samuel Ortion | f1be35255c | |
Samuel Ortion | e4488e1918 | |
Samuel Ortion | 2bedbac279 | |
Samuel Ortion | 6b37e1cfc5 | |
Samuel Ortion | 0399fa085e | |
Samuel Ortion | 29dcd4c999 |
|
@ -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.
|
|
@ -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.
|
|
@ -4,4 +4,8 @@ var/
|
|||
|
||||
.env
|
||||
|
||||
species_list.txt
|
||||
species_list.txt
|
||||
|
||||
push.sh
|
||||
|
||||
config/*.conf
|
|
@ -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()
|
||||
{
|
||||
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
<?php
|
||||
echo shell_exec("journalctl -u birdnet_recording -n 10");
|
|
@ -0,0 +1,3 @@
|
|||
<?php
|
||||
|
||||
echo urlencode("contact@ortion.fr");
|
|
@ -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
|
|
@ -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.
|
216
INSTALL.md
216
INSTALL.md
|
@ -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.)
|
||||
|
|
32
README.md
32
README.md
|
@ -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.
|
|
@ -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"
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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')"
|
||||
}
|
|
@ -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)
|
||||
);
|
|
@ -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
|
|
@ -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()
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=BirdNET-stream miner Timer
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/15
|
||||
Unit=birdnet_miner.service
|
||||
|
||||
[Install]
|
||||
WantedBy=basic.target
|
|
@ -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
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=BirdNET-stream plotter timer
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:00
|
||||
Unit=birdnet_plotter.service
|
||||
|
||||
[Install]
|
||||
WantedBy=basic.target
|
|
@ -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
|
|
@ -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
|
105
install.sh
105
install.sh
|
@ -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
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
|
@ -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
|
||||
|
|
|
@ -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,5 +1,6 @@
|
|||
|
||||
###> symfony/framework-bundle ###
|
||||
!.env
|
||||
/.env.local
|
||||
/.env.local.php
|
||||
/.env.*.local
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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]}/`;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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],
|
||||
];
|
||||
|
|
|
@ -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
|
|
@ -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%'
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
###> doctrine/doctrine-bundle ###
|
||||
database:
|
||||
ports:
|
||||
- "5432"
|
||||
###< doctrine/doctrine-bundle ###
|
|
@ -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,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;
|
||||
}
|
|
@ -14,5 +14,8 @@
|
|||
"dev": "encore dev",
|
||||
"watch": "encore dev --watch",
|
||||
"build": "encore production --progress"
|
||||
},
|
||||
"dependencies": {
|
||||
"feather-icons": "^4.29.0"
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
|
@ -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', [
|
||||
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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', [
|
||||
|
||||
]);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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": {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -1,4 +1,4 @@
|
|||
<footer>
|
||||
<footer class="end">
|
||||
<p>
|
||||
© <span class="creation-date">2022
|
||||
<!-- check if the current year is the same as the creation year -->
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -0,0 +1,3 @@
|
|||
<audio preload="none" controls>
|
||||
<source src="/media/records/{{ filename }}">
|
||||
</audio>
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -0,0 +1,2 @@
|
|||
<img class="logo" src="/media/logo.svg" alt="BirdNET-stream logo">
|
||||
<h1>BirdNET-stream</h1>
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -0,0 +1,9 @@
|
|||
<div class="date-selector container row">
|
||||
<label for="date">{{ "Date" | trans }}</label>
|
||||
<button class="previous-date-button button"><</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">></button>
|
||||
<a href="/{{ endpoint }}/{{ date ? date : " now" | date("Y-m-d") }}/" class="button main">{{ "Go" | trans }}</a>
|
||||
</div>
|
||||
|
||||
{{ encore_entry_script_tags('date') }}
|
|
@ -0,0 +1,6 @@
|
|||
<li>
|
||||
<a class="{{ app.request.get('_route') matches '{' ~ route ~ '_*}' ? 'active' }}"
|
||||
href="{{ url }}">
|
||||
{{ name }}
|
||||
</a>
|
||||
</li>
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue