diff --git a/Readme.md b/Readme.md index 543c1a0..3e8fb94 100644 --- a/Readme.md +++ b/Readme.md @@ -2,7 +2,9 @@ A simple for recording audio, and playing a frequency when the volume is over some threshold. -This app has many uses, but is made originally for training a dog to stop barking. By playing a high pitched, loud sound whenever my computer detected high volume noise, I trained my dog to stop barking while i was gone. +This app has many uses, but is made originally for training a dog to stop barking. By playing a high pitched, loud sound whenever my computer detected high volume noise, I trained my dog to stop barking while I was gone. + +It will also email you about events with recordings and summarize the events throughout the day. # Installation @@ -22,7 +24,11 @@ pre-commit install # Usage -> python3 -m dogbarking +> ./run.sh + +**NOTE**: Only tapping CTRL+C *twice* will stop the app using this script. + +This will start the app and record audio from your default input device. When the volume is over the threshold, it will play a high pitched sound. # Email diff --git a/dogbarking/__main__.py b/dogbarking/__main__.py index ac1ab2b..5647306 100644 --- a/dogbarking/__main__.py +++ b/dogbarking/__main__.py @@ -1,4 +1,5 @@ from pathlib import Path +import textwrap import sys from typing import Annotated, Optional from datetime import datetime @@ -9,8 +10,9 @@ from typer_config import use_toml_config from dogbarking.audio import Player, Recorder -from dogbarking.email import Email +from dogbarking.email import Email, match_cron from dogbarking.math import get_rms +import pandas as pd from loguru import logger app = typer.Typer() @@ -20,30 +22,30 @@ @use_toml_config(default_value="config.toml") def nogui( volume: Annotated[ - float, typer.Argument(help="The volume to play the sound at.", min=0.0, max=1.0) + float, typer.Option(help="The volume to play the sound at.", min=0.0, max=1.0) ] = 1.0, thresh: Annotated[ float, - typer.Argument(help="The threshold to trigger the sound.", min=0.0, max=1.0), + typer.Option(help="The threshold to trigger the sound.", min=0.0, max=1.0), ] = 0.1, frequency: Annotated[ - float, typer.Argument(help="The frequency of the sound to play.", min=0.0) + float, typer.Option(help="The frequency of the sound to play.", min=0.0) ] = 17000.0, duration: Annotated[ float, - typer.Argument(help="The duration to play the sound in seconds.", min=0.0), + typer.Option(help="The duration to play the sound in seconds.", min=0.0), ] = 5.0, sample_freq: Annotated[ - int, typer.Argument(help="The sample rate in Hz.", min=0) + int, typer.Option(help="The sample rate in Hz.", min=0) ] = 44100, keep_historical_seconds: Annotated[ - int, typer.Argument(help="The number of seconds to save to audio.", min=0) + int, typer.Option(help="The number of seconds to save to audio.", min=0) ] = 10, seconds_per_buffer: Annotated[ - float, typer.Argument(help="The number of seconds per buffer.", min=0.0) + float, typer.Option(help="The number of seconds per buffer.", min=0.0) ] = 0.1, save_path: Annotated[ - Path, typer.Argument(help="The path to save the audio file to.") + Path, typer.Option(help="The path to save the audio file to.") ] = Path("./outputs"), sender_email: Annotated[ Optional[str], typer.Option(help="The email to send the alert from.") @@ -75,9 +77,15 @@ def nogui( help="The logging level to use.", ), ] = "INFO", + summary_cron: Annotated[ + str, typer.Option(help="The cron schedule to send a summary email.") + ] = "*/30 * * * *", ): """Dog Barking Alert System""" + # Start time + start_time = datetime.now() + # Set up the logger logger.remove(0) logger.add( @@ -98,6 +106,27 @@ def nogui( raise typer.Abort() logger.warning("Remember to turn your volume all the way up!") + logger.warning( + "Remember to ensure your laptop is plugged in and set to never sleep!" + ) + + # Send a start email + if use_email: + assert sender_email is not None + assert receiver_email is not None + assert smtp_password is not None + assert smtp_server is not None + assert smtp_port is not None + logger.info("Sending start email...") + Email( + sender_email=sender_email, + receiver_email=receiver_email, + smtp_password=SecretStr(smtp_password), + smtp_server=smtp_server, + smtp_port=smtp_port, + summary=f"Dog Barking App Starting {start_time}", + body="The dog barking detection has started.", + ).send_email() # Start Recording audio = pyaudio.PyAudio() @@ -117,11 +146,45 @@ def nogui( r.start() # If the rms of the waveform is greater than the threshold, play the sound + rms_history = [] + nb_events = 0 for waveform in r: rms = get_rms(waveform) logger.debug(f"RMS: {rms}") + rms_history.append(rms) + + # Handle summary email + # Do not track seconds + if ( + match_cron(summary_cron) + and use_email + and len(rms_history) > 0 + and len(rms_history) % (int(1 / seconds_per_buffer) * 60) == 0 + ): + r.stop() + assert sender_email is not None + assert receiver_email is not None + assert smtp_password is not None + assert smtp_server is not None + assert smtp_port is not None + logger.info("Sending summary email...") + Email( + sender_email=sender_email, + receiver_email=receiver_email, + smtp_password=SecretStr(smtp_password), + smtp_server=smtp_server, + smtp_port=smtp_port, + summary=f"Dog Barking Summary Email {datetime.now()}", + body=f"Here are some statistics about the dog barking:\n{pd.DataFrame(rms_history, columns=['RMS']).describe()}\n\nThreshold: {thresh}\nNumber of Events: {nb_events}", + ).send_email() + r.start() + rms_history = [] + nb_events = 0 + + # Handle thresholding if rms > thresh: logger.info(f"Dog Barking at {datetime.now()}") + nb_events += 1 # Stop the recording, don't want to record the sound we are playing r.stop() @@ -130,7 +193,7 @@ def nogui( p.play_sound() # Save the recording and send the email - filepath = save_path / f"{datetime.now().isoformat()}.mp3" + filepath = save_path / str(start_time) / f"{datetime.now().isoformat()}.mp3" r.save(filepath) if use_email: assert sender_email is not None @@ -145,6 +208,14 @@ def nogui( smtp_password=SecretStr(smtp_password), smtp_server=smtp_server, smtp_port=smtp_port, + summary=f"Dog Barking Alert {datetime.now().isoformat()}", + body=textwrap.dedent( + f"""\ + Your dog was barking at {datetime.now().isoformat()}. + RMS: {rms} + Threshold: {thresh} + """ + ), ).send_email() # Start recording again diff --git a/dogbarking/email.py b/dogbarking/email.py index 59029fc..47353ff 100644 --- a/dogbarking/email.py +++ b/dogbarking/email.py @@ -1,5 +1,6 @@ from pathlib import Path -import textwrap +from typing import Optional +from croniter import croniter from pydantic import BaseModel, EmailStr, SecretStr import smtplib import ssl @@ -11,13 +12,22 @@ from loguru import logger +def match_cron(cron_string: str) -> bool: + """ + Check if the current time matches the cron schedule. + """ + return croniter.match(cron_string, datetime.now()) + + class Email(BaseModel): + summary: str + body: str sender_email: EmailStr receiver_email: EmailStr - attachment_filepath: Path smtp_password: SecretStr smtp_server: str smtp_port: int = 465 + attachment_filepath: Optional[Path] = None class Config: arbitrary_types_allowed = True @@ -27,28 +37,26 @@ def _create_message(self) -> MIMEMultipart: message = MIMEMultipart() message["From"] = self.sender_email message["To"] = self.receiver_email - message["Subject"] = f"Dog Barking Alert {datetime.now().isoformat()}" - body = textwrap.dedent( - f"""\ - Your dog was barking at {datetime.now().isoformat()}. - """ - ) + message["Subject"] = self.summary + body = self.body # Add body to email message.attach(MIMEText(body, "plain")) - # Open PDF file in binary mode and attach - with self.attachment_filepath.open("rb") as attachment: - part = MIMEBase("application", "octet-stream") - part.set_payload(attachment.read()) + # Open file in binary mode and attach + if self.attachment_filepath is not None: + with self.attachment_filepath.open("rb") as attachment: + part = MIMEBase("application", "octet-stream") + part.set_payload(attachment.read()) + + # Encode file in ASCII characters to send by email + encoders.encode_base64(part) - # Encode file in ASCII characters to send by email - encoders.encode_base64(part) - part.add_header( - "Content-Disposition", - f"attachment; filename= {str(self.attachment_filepath)}", - ) - message.attach(part) + part.add_header( + "Content-Disposition", + f"attachment; filename= {str(self.attachment_filepath)}", + ) + message.attach(part) return message diff --git a/poetry.lock b/poetry.lock index db91594..9cf0f72 100644 --- a/poetry.lock +++ b/poetry.lock @@ -286,6 +286,21 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "croniter" +version = "2.0.2" +description = "croniter provides iteration for datetime object with cron like format" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "croniter-2.0.2-py2.py3-none-any.whl", hash = "sha256:78bf110a2c7dbbfdd98b926318ae6c64a731a4c637c7befe3685755110834746"}, + {file = "croniter-2.0.2.tar.gz", hash = "sha256:8bff16c9af4ef1fb6f05416973b8f7cb54997c02f2f8365251f9bf1dded91866"}, +] + +[package.dependencies] +python-dateutil = "*" +pytz = ">2021.1" + [[package]] name = "distlib" version = "0.3.8" @@ -1621,6 +1636,17 @@ python-dotenv = ["python-dotenv"] toml = ["toml (>=0.10.2,<0.11.0)"] yaml = ["pyyaml (>=6.0,<7.0)"] +[[package]] +name = "types-croniter" +version = "2.0.0.20240106" +description = "Typing stubs for croniter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-croniter-2.0.0.20240106.tar.gz", hash = "sha256:a5c92566d750e025ab31279029ab44b479e2e3509cd8db3784574bdab1012571"}, + {file = "types_croniter-2.0.0.20240106-py3-none-any.whl", hash = "sha256:266d9ecabbc06afab7cc0cfa7f2149eb36f613ed66ddd6c9bac4edcf727e9a58"}, +] + [[package]] name = "types-pyaudio" version = "0.2.16.20240106" @@ -1760,4 +1786,4 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "39459ee4abd448ca12cf8124032b2860e7d51d769a0c13ec94cdc678af3b96e2" +content-hash = "6dff8ff89aa51384083ebaa6c78df19f5a0e36d313059be7a00b0a720e831fae" diff --git a/pyproject.toml b/pyproject.toml index f8aa9f5..7abe016 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ typer-config = "^1.4.0" ruff = "^0.3.3" loguru = "^0.7.2" soundfile = "^0.12.1" +croniter = "^2.0.2" [tool.poetry.group.dev.dependencies] mypy = "^1.9.0" @@ -25,6 +26,7 @@ pre-commit = "^3.6.2" types-pyaudio = "^0.2.16.20240106" types-toml = "^0.10.8.20240310" ruff = "^0.3.3" +types-croniter = "^2.0.0.20240106" [build-system] requires = ["poetry-core"] diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..fa4337d --- /dev/null +++ b/run.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# This script is used to run the dogbarking module +# It will restart the module if it crashes +# It will also pass any command line arguments to the module + +while true; do + /usr/bin/env python3 -m dogbarking $@ + rc=$? + if [ $rc -eq 0 ]; then + exit 0 + else + echo "Restarting. Press Ctrl+C (again) to exit" + sleep 1 + fi +done