Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Now sends a summary and startup email #5

Merged
merged 8 commits into from
Mar 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
91 changes: 81 additions & 10 deletions dogbarking/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pathlib import Path
import textwrap
import sys
from typing import Annotated, Optional
from datetime import datetime
Expand All @@ -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()
Expand All @@ -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.")
Expand Down Expand Up @@ -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(
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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
Expand Down
46 changes: 27 additions & 19 deletions dogbarking/email.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
28 changes: 27 additions & 1 deletion poetry.lock

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

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ 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"
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"]
Expand Down
16 changes: 16 additions & 0 deletions run.sh
Original file line number Diff line number Diff line change
@@ -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
Loading