import glob import json import logging import os import sqlite3 import subprocess import tempfile import io import soundfile from time import sleep import requests from PIL import Image, ImageDraw, ImageFont from .helpers import get_settings, ParseFileName, Detection, get_font, DB_PATH from .notifications import sendAppriseNotifications log = logging.getLogger(__name__) def extract(in_file, out_file, start, stop): result = subprocess.run(['sox', '-V1', f'{in_file}', f'{out_file}', 'trim', f'={start}', f'={stop}'], check=True, capture_output=True) ret = result.stdout.decode('utf-8') err = result.stderr.decode('utf-8') if err: raise RuntimeError(f'{ret}:\n {err}') return ret def extract_safe(in_file, out_file, start, stop): conf = get_settings() # This section sets the SPACER that will be used to pad the audio clip with # context. If EXTRACTION_LENGTH is 10, for instance, 3 seconds are removed # from that value and divided by 2, so that the 3 seconds of the call are # within 3.5 seconds of audio context before and after. try: ex_len = conf.getint('EXTRACTION_LENGTH') except ValueError: ex_len = 6 spacer = (ex_len - 3) / 2 safe_start = max(0, start - spacer) safe_stop = min(conf.getint('RECORDING_LENGTH'), stop + spacer) extract(in_file, out_file, safe_start, safe_stop) def spectrogram(in_file, title, comment, raw=False): fd, tmp_file = tempfile.mkstemp(suffix='.png') os.close(fd) args = ['sox', '-V1', f'{in_file}', '-n', 'remix', '1', 'rate', '24k', 'spectrogram', '-t', '', '-c', '', '-o', tmp_file] args += ['-r'] if raw else [] result = subprocess.run(args, check=True, capture_output=True) ret = result.stdout.decode('utf-8') err = result.stderr.decode('utf-8') if err: raise RuntimeError(f'{ret}:\n {err}') img = Image.open(tmp_file) height = img.size[1] width = img.size[0] draw = ImageDraw.Draw(img) title_font = ImageFont.truetype(get_font()['path'], 13) _, _, w, _ = draw.textbbox((0, 0), title, font=title_font) draw.text(((width-w)/2, 6), title, fill="white", font=title_font) comment_font = ImageFont.truetype(get_font()['path'], 11) _, _, _, h = draw.textbbox((0, 0), comment, font=comment_font) draw.text((1, height - (h + 1)), comment, fill="white", font=comment_font) img.save(f'{in_file}.png') os.remove(tmp_file) def extract_detection(file: ParseFileName, detection: Detection): conf = get_settings() new_file_name = f'{detection.common_name_safe}-{detection.confidence_pct}-{detection.date}-birdnet-{file.RTSP_id}{detection.time}.{conf["AUDIOFMT"]}' new_dir = os.path.join(conf['EXTRACTED'], 'By_Date', f'{detection.date}', f'{detection.common_name_safe}') new_file = os.path.join(new_dir, new_file_name) if os.path.isfile(new_file): log.warning('Extraction exists. Moving on: %s', new_file) else: os.makedirs(new_dir, exist_ok=True) extract_safe(file.file_name, new_file, detection.start, detection.stop) spectrogram(new_file, detection.common_name, new_file.replace(os.path.expanduser('~/'), '')) return new_file def write_to_db(file: ParseFileName, detection: Detection): conf = get_settings() # Connect to SQLite Database for attempt_number in range(3): try: con = sqlite3.connect(DB_PATH) cur = con.cursor() cur.execute("INSERT INTO detections VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (detection.date, detection.time, detection.scientific_name, detection.common_name, detection.confidence, conf['LATITUDE'], conf['LONGITUDE'], conf['CONFIDENCE'], str(detection.week), conf['SENSITIVITY'], conf['OVERLAP'], os.path.basename(detection.file_name_extr))) # (Date, Time, Sci_Name, Com_Name, str(score), # Lat, Lon, Cutoff, Week, Sens, # Overlap, File_Name)) con.commit() con.close() break except BaseException as e: log.warning("Database busy: %s", e) sleep(2) def summary(file: ParseFileName, detection: Detection): # Date;Time;Sci_Name;Com_Name;Confidence;Lat;Lon;Cutoff;Week;Sens;Overlap # 2023-03-03;12:48:01;Phleocryptes melanops;Wren-like Rushbird;0.76950216;-1;-1;0.7;9;1.25;0.0 conf = get_settings() s = (f'{detection.date};{detection.time};{detection.scientific_name};{detection.common_name};' f'{detection.confidence};' f'{conf["LATITUDE"]};{conf["LONGITUDE"]};{conf["CONFIDENCE"]};{detection.week};{conf["SENSITIVITY"]};' f'{conf["OVERLAP"]}') return s def write_to_file(file: ParseFileName, detection: Detection): with open(os.path.expanduser('~/BirdNET-Pi/BirdDB.txt'), 'a') as rfile: rfile.write(f'{summary(file, detection)}\n') def update_json_file(file: ParseFileName, detections: [Detection]): if file.RTSP_id is None: mask = f'{os.path.dirname(file.file_name)}/*.json' else: mask = f'{os.path.dirname(file.file_name)}/*{file.RTSP_id}*.json' for f in glob.glob(mask): log.debug(f'deleting {f}') os.remove(f) write_to_json_file(file, detections) def write_to_json_file(file: ParseFileName, detections: [Detection]): conf = get_settings() json_file = f'{file.file_name}.json' log.debug(f'WRITING RESULTS TO {json_file}') dets = {'file_name': os.path.basename(json_file), 'timestamp': file.iso8601, 'delay': conf['RECORDING_LENGTH'], 'detections': [{"start": det.start, "common_name": det.common_name, "confidence": det.confidence} for det in detections]} with open(json_file, 'w') as rfile: rfile.write(json.dumps(dets)) log.debug(f'DONE! WROTE {len(detections)} RESULTS.') def apprise(file: ParseFileName, detections: [Detection]): species_apprised_this_run = [] conf = get_settings() for detection in detections: # Apprise of detection if not already alerted this run. if detection.species not in species_apprised_this_run: try: sendAppriseNotifications(detection.species, str(detection.confidence), str(detection.confidence_pct), os.path.basename(detection.file_name_extr), detection.date, detection.time, str(detection.week), conf['LATITUDE'], conf['LONGITUDE'], conf['CONFIDENCE'], conf['SENSITIVITY'], conf['OVERLAP'], dict(conf), DB_PATH) except BaseException as e: log.exception('Error during Apprise:', exc_info=e) species_apprised_this_run.append(detection.species) def bird_weather(file: ParseFileName, detections: [Detection]): conf = get_settings() if conf['BIRDWEATHER_ID'] == "": return if detections: try: data, samplerate = soundfile.read(file.file_name) buf = io.BytesIO() soundfile.write(buf, data, samplerate, format='FLAC') flac_data = buf.getvalue() except Exception as e: log.error("Error during FLAC conversion: %s", e) return # POST soundscape to server soundscape_url = (f'https://app.birdweather.com/api/v1/stations/' f'{conf["BIRDWEATHER_ID"]}/soundscapes?timestamp={file.iso8601}') try: response = requests.post(url=soundscape_url, data=flac_data, timeout=30, headers={'Content-Type': 'audio/flac'}) log.info("Soundscape POST Response Status - %d", response.status_code) sdata = response.json() except BaseException as e: log.error("Cannot POST soundscape: %s", e) return if not sdata.get('success'): log.error(sdata.get('message')) return soundscape_id = sdata['soundscape']['id'] for detection in detections: # POST detection to server detection_url = f'https://app.birdweather.com/api/v1/stations/{conf["BIRDWEATHER_ID"]}/detections' data = {'timestamp': detection.iso8601, 'lat': conf['LATITUDE'], 'lon': conf['LONGITUDE'], 'soundscapeId': soundscape_id, 'soundscapeStartTime': detection.start, 'soundscapeEndTime': detection.stop, 'commonName': detection.common_name, 'scientificName': detection.scientific_name, 'algorithm': '2p4' if conf['MODEL'] == 'BirdNET_GLOBAL_6K_V2.4_Model_FP16' else 'alpha', 'confidence': detection.confidence} log.debug(data) try: response = requests.post(detection_url, json=data, timeout=20) log.info("Detection POST Response Status - %d", response.status_code) except BaseException as e: log.error("Cannot POST detection: %s", e) def heartbeat(): conf = get_settings() if conf['HEARTBEAT_URL']: try: result = requests.get(url=conf['HEARTBEAT_URL'], timeout=10) log.info('Heartbeat: %s', result.text) except BaseException as e: log.error('Error during heartbeat: %s', e)