Skip to content

Commit

Permalink
Merge pull request #5 from ryanpeach/feature/summary-emails
Browse files Browse the repository at this point in the history
Now sends a summary and startup email
  • Loading branch information
ryanpeach authored Mar 16, 2024
2 parents b009cd4 + 139670f commit 523abe1
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 32 deletions.
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

0 comments on commit 523abe1

Please sign in to comment.