add sphinx docs, comments, safety features

This commit is contained in:
Nick Pourazima 2023-12-21 11:57:41 -05:00
parent ccb48d89ec
commit e787bde452
16 changed files with 1011 additions and 142 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
*.code*
*.trunk*
__pycache__

View File

@ -6,7 +6,7 @@ After=multi-user.target
WorkingDirectory=<path-to-project>
Type=simple
Restart=always
ExecStart=/usr/bin/env python3 <path-to-project>/audioGuestBook.py
ExecStart=/usr/bin/env python3 <path-to-project>/src/audioGuestBook.py
[Install]
WantedBy=multi-user.target

View File

@ -1,134 +0,0 @@
#! /usr/bin/env python3
import logging
import time
import wave
from typing import List
import pyaudio
from pydub import AudioSegment
from pydub.effects import compress_dynamic_range, normalize
from pydub.scipy_effects import band_pass_filter
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class AudioInterface:
def __init__(
self,
hook,
buffer_size,
channels,
format,
sample_rate,
recording_limit,
dev_index,
hook_type,
) -> None:
self.chunk = buffer_size
self.chans = channels
self.format = format
self.frames: List[bytes] = []
self.hook = hook
self.samp_rate = sample_rate
self.recording_limit = recording_limit
self.dev_index = dev_index
self.hook_type = hook_type
self.audio = None
self.stream = None
def init_audio(self):
if self.audio is None:
self.audio = pyaudio.PyAudio()
if self.stream is not None:
self.stream.stop_stream()
self.stream.close()
self.stream = None
def record(self):
self.init_audio()
self.stream = self.audio.open(
format=self.format,
rate=self.samp_rate,
channels=self.chans,
input_device_index=self.dev_index,
input=True,
frames_per_buffer=self.chunk,
)
# loop through stream and append audio chunks to frame array
try:
start = time.time()
while self.off_hook_condition():
if time.time() - start < self.recording_limit:
data = self.stream.read(self.chunk, exception_on_overflow=True)
self.frames.append(data)
else:
# Notify the user that their recording time is up
self.play("time_exceeded.wav")
break
except KeyboardInterrupt:
logger.info("Done recording")
except Exception as e:
logger.error(str(e))
def off_hook_condition(self):
if self.hook_type == "NC":
return not self.hook.is_pressed
else: # Assuming default is "NO" if not "NC"
return self.hook.is_pressed
def play(self, file):
self.init_audio()
with wave.open(file, "rb") as wf:
self.stream = self.audio.open(
format=self.audio.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=wf.getframerate(),
output=True,
)
data = wf.readframes(self.chunk)
while data:
self.stream.write(data)
data = wf.readframes(self.chunk)
def stop(self):
if self.stream:
self.stream.stop_stream()
self.stream.close()
if self.audio:
self.audio.terminate()
def close(self, output_file):
try:
with wave.open(output_file, "wb") as wavefile:
wavefile.setnchannels(self.chans)
wavefile.setsampwidth(self.audio.get_sample_size(self.format))
wavefile.setframerate(self.samp_rate)
wavefile.writeframes(b"".join(self.frames))
except OSError as e:
logger.error(f"Error writing to file {output_file}. Error: {e}")
def post_process(self, output_file):
source = AudioSegment.from_wav(output_file + ".wav")
filtered = self.filter_audio(source)
normalized = self.normalize_audio(filtered)
compressed = self.compress_audio(normalized)
normalized.export(output_file + "normalized.wav", format="wav")
compressed.export(output_file + "compressed.mp3", format="mp3")
def filter_audio(self, audio):
logger.info("Filtering...")
return band_pass_filter(audio, 300, 10000)
def normalize_audio(self, audio):
logger.info("Normalizing...")
return normalize(audio)
def compress_audio(self, audio):
logger.info("Compress Dynamic Range")
return compress_dynamic_range(audio)

View File

@ -9,6 +9,5 @@ rotary_gpio: 23
rotary_hold_repeat: true
rotary_hold_time: 0.25
sample_rate: 44100
source_file: audioGuestBook.py
format: INT16
hook_type: NC # or 'NO'

2
docs/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
_build
_static/images

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

548
docs/README.rst Normal file
View File

@ -0,0 +1,548 @@
Rotary Phone Audio Guestbook
============================
This project transforms a rotary phone into a voice recorder for use at
special events (i.e. wedding audio guestbook, etc.).
.. figure:: _static/images/final_result_2.jpg
:alt: image
image
- `Rotary Phone Audio Guestbook <#rotary-phone-audio-guestbook>`__
- `Background <#background>`__
- `Post-Event Reflection <#post-event-reflection>`__
- `Future Enhancements <#future-enhancements>`__
- `Quick-Start <#quick-start>`__
- `Materials <#materials>`__
- `Hardware <#hardware>`__
- `Wiring <#wiring>`__
- `Hook <#hook>`__
- `Phone Cord <#phone-cord>`__
- `Optional: Microphone
Replacement <#optional-microphone-replacement>`__
- `Software <#software>`__
- `Dev Environment <#dev-environment>`__
- `Installation <#installation>`__
- `audioGuestBook systemctl
service <#audioguestbook-systemctl-service>`__
- `Config <#config>`__
- `AudioInterface Class <#audiointerface-class>`__
- `Operation Mode 1:
audioGuestBook <#operation-mode-1-audioguestbook>`__
- `Operation Mode 2:
audioGuestBookwithRotaryDialer <#operation-mode-2-audioguestbookwithrotarydialer>`__
- `Troubleshooting <#troubleshooting>`__
- `Configuring Hook Type <#configuring-hook-type>`__
- `Verify default audio
interface <#verify-default-audio-interface>`__
- `Check the Sound Card
Configuration <#check-the-sound-card-configuration>`__
- `Set the Default Sound Card <#set-the-default-sound-card>`__
- `Restart ALSA <#restart-alsa>`__
- `Support <#support>`__
Background
----------
Inspired by my own upcoming wedding, I created a DIY solution for an
audio guestbook using a rotary phone. With most online rentals charging
exorbitant fees without offering custom voicemail options, I sought a
more affordable and customizable solution. Here, Ive detailed a guide
on creating your own audio guestbook. If you have questions, dont
hesitate to reach out.
Post-Event Reflection
---------------------
The real event provided insights into areas of improvement for the
setup. For instance, introducing a recording time limit became essential
after some younger attendees left lengthy messages, draining the
battery. Depending on the situation, you might also consider connecting
the setup directly to a 5V power supply.
Future Enhancements
-------------------
In anticipation of my wedding, I had code in place to detect dialed
numbers from the rotary encoder, allowing us to play special messages
for specific guests based on their dialed combination. However, this
required users to dial zero before leaving a voice message, introducing
an extra step. We opted for simplicity, but if youre interested in
expanding on this, youre welcome to explore further. The details of
this operation mode are described in `Mode
2 <#operation-mode-2-audioguestbookwithrotarydialer>`__
Additionally, threading the audio playback would be beneficial, allowing
for a watchdog service to terminate the thread upon a hook callback.
This would stop the message playback when a user hangs up.
Quick-Start
-----------
After cloning the repo on the rpi:
.. code:: bash
chmod +x installer.sh
./installer.sh
Materials
---------
.. raw:: html
<details>
Parts List
+------------------------------------------+------------------------+---+---+
| Part | Notes | Q | C |
| | | u | o |
| | | a | s |
| | | n | t |
| | | t | |
| | | i | |
| | | t | |
| | | y | |
+==========================================+========================+===+===+
| `rotary | Estate/garage/yard | 1 | $ |
| phone <https://www.ebay.com/b/Rot | sales are probably the | | 0 |
| ary-Dial-Telephone/38038/bn_55192308>`__ | best places to find | | . |
| | once of these. Ideally | | 0 |
| | one with a phone jack | | 0 |
| | since we will be using | | - |
| | these four wires | | $ |
| | extensively. | | 6 |
| | | | 0 |
| | | | . |
| | | | 0 |
| | | | 0 |
+------------------------------------------+------------------------+---+---+
| `raspberry pi | I didnt realize how | 1 | $ |
| zero <https://www.raspber | hard these are to find | | 9 |
| rypi.com/products/raspberry-pi-zero/>`__ | these days. You can | | . |
| | use any rpi or arduino | | 9 |
| | style single-board | | 9 |
| | computer but be aware | | |
| | of size constraints | | |
| | (i.e. must fit inside | | |
| | the rotary phone | | |
| | enclosure) | | |
+------------------------------------------+------------------------+---+---+
| `raspberry pi zero | Optional: added for | 1 | $ |
| case <h | protection. One of the | | 4 |
| ttps://www.adafruit.com/product/3252>`__ | cases on Amazon has a | | . |
| | heat-sink cutout which | | 9 |
| | might be nice for | | 5 |
| | better heat | | |
| | dissapation since it | | |
| | will all be enclosed | | |
| | in the end. | | |
+------------------------------------------+------------------------+---+---+
| `micro SD | Any high | 1 | $ |
| card <https://a.co/d/1gb2zhC>`__ | capacity/throughput | | 8 |
| | micro SD card that is | | . |
| | rpi compatible | | 9 |
| | | | 9 |
+------------------------------------------+------------------------+---+---+
| `USB Audio | Note: I removed the | 1 | $ |
| Adapter <h | external plastic shell | | 4 |
| ttps://www.adafruit.com/product/1475>`__ | and directly soldered | | . |
| | the wires instead of | | 9 |
| | using the female 3.5mm | | 5 |
| | receptacle. | | |
+------------------------------------------+------------------------+---+---+
| `USB OTG Host Cable - MicroB OTG male to | | 1 | $ |
| A | | | 2 |
| female <h | | | . |
| ttps://www.adafruit.com/product/1099>`__ | | | 5 |
| | | | 0 |
+------------------------------------------+------------------------+---+---+
| — | **— If you dont want | — | — |
| | to solder anything —** | | |
+------------------------------------------+------------------------+---+---+
| `3.5mm Male to Screw Terminal | Optional: can connect | 2 | $ |
| Connector <https://www | the handset cables | | 1 |
| .parts-express.com/3.5mm-Male-to-Screw-T | directly to the USB | | . |
| erminal-Connector-090-110?quantity=1&utm | audio interface via | | 3 |
| _source=google&utm_medium=cpc&utm_campai | these screw terminals | | 7 |
| gn=18395892906&utm_content=145242146127& | | | |
| gadid=623430178298&gclid=CjwKCAiAioifBhA | | | |
| XEiwApzCztl7aVb18WP4hDxnlQUCHsb62oIcnduF | | | |
| CSCbn9LFkZovYTQdr6omb3RoCD_gQAvD_BwE>`__ | | | |
+------------------------------------------+------------------------+---+---+
| — | **— If running off a | — | — |
| | battery —** | | |
+------------------------------------------+------------------------+---+---+
| `LiPo | Optional: maximize | 1 | $ |
| Battery <h | capacity based on what | | 1 |
| ttps://www.adafruit.com/product/2011>`__ | will fit within your | | 2 |
| | rotary enclosure. | | . |
| | | | 5 |
| | | | 0 |
+------------------------------------------+------------------------+---+---+
| `LiPo | Optional: if you plan | 1 | $ |
| Shim <h | to run this off a LiPo | | 9 |
| ttps://www.adafruit.com/product/3196>`__ | I would recommend | | . |
| | something like this to | | 9 |
| | interface with the rpi | | 5 |
| | zero. | | |
+------------------------------------------+------------------------+---+---+
| `LiPo | Optional: for | 1 | $ |
| Charger <h | re-charging the LiPo. | | 6 |
| ttps://www.adafruit.com/product/1904>`__ | | | . |
| | | | 9 |
| | | | 5 |
+------------------------------------------+------------------------+---+---+
| — | **— If replacing the | — | — |
| | built-it microphone | | |
| | —** | | |
+------------------------------------------+------------------------+---+---+
| `LavMic <https://www | Optional: if youd | 1 | $ |
| .amazon.com/dp/B01N6P80OQ?ref=nb_sb_ss_w | like to replace the | | 2 |
| _as-reorder-t1_ypp_rep_k3_1_9&amp=&crid= | carbon microphone. | | 4 |
| 15WZEWMZ17EM9&amp=&sprefix=saramonic>`__ | This is an | | . |
| | omnidirectional | | 9 |
| | lavalier mic and | | 5 |
| | outputs via a 3.5mm | | |
| | TRS | | |
+------------------------------------------+------------------------+---+---+
.. raw:: html
</details>
Hardware
--------
Wiring
~~~~~~
Hook
^^^^
**Understanding Hook Types:** Depending on your rotary phone model, the
hook switch may be Normally Closed (NC) or Normally Open (NO). When the
phone is on the hook:
- NC: The circuit is closed (current flows).
- NO: The circuit is open (no current).
To accommodate either type, youll need to update the ``config.yaml``
with the appropriate hook type setting.
- Use multimeter to do a continuity check to find out which pins
control the hook:
==================================== ===========================
On-hook > Open circuit (Value == 1) Off-hook > Current flowing
==================================== ===========================
|hook1| |hook2|
==================================== ===========================
- The B screw terminal on the rotary phone is connected to the black
wire which is grounded to the rpi.
- The L2 screw terminal on the rotary phone is connected to the white
wire which is connected to GPIO pin 22 on the rpi.
.. figure:: _static/images/pi_block_terminal_wiring.jpg
:alt: image
image
- *Note: the green wire was used for the experimental rotary encoder
feature identified in the*\ `future
work <#future-enhancements>`__\ *section.*
=========================== =============
Rotary Phone Block Terminal Top-down view
=========================== =============
|term1| |term2|
=========================== =============
Phone Cord
^^^^^^^^^^
- The wires from the handset cord need to be connected to the USB audio
interface
- I soldered it but you can alternatively use 2x `3.5mm Male to
Screw Terminal
Connector <https://www.parts-express.com/3.5mm-Male-to-Screw-Terminal-Connector-090-110?quantity=1&utm_source=google&utm_medium=cpc&utm_campaign=18395892906&utm_content=145242146127&gadid=623430178298&gclid=CjwKCAiAioifBhAXEiwApzCztl7aVb18WP4hDxnlQUCHsb62oIcnduFCSCbn9LFkZovYTQdr6omb3RoCD_gQAvD_BwE>`__
which plug directly into the rpi.
- *Note: The USB audio interface looks weird in the pics since I
stripped the plastic shell off in order to solder directly to
the mic/speaker leads*
.. figure:: _static/images/dissected_view_1.jpg
:alt: image
image
- Use this ALSA command from the command line to test if the mic is
working on the rpi before you set up the rotary phone: ``aplay -l``
- You might have a different hardware mapping than I did, in which
case you would change the ``alsa_hw_mapping`` in the
`config.yaml <config.yaml>`__.
- `Heres <https://superuser.com/questions/53957/what-do-alsa-devices-like-hw0-0-mean-how-do-i-figure-out-which-to-use>`__
a good reference to device selection.
- You can also check
`this <https://stackoverflow.com/questions/32838279/getting-list-of-audio-input-devices-in-python>`__
from Python.
Optional: Microphone Replacement
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
For improved sound quality, consider replacing the built-in `carbon
microphone <https://en.wikipedia.org/wiki/Carbon_microphone>`__.
I found the sound quality of the built-in mic on the rotary phone to be
quite lacking in terms of amplitude, dynamic range and overall vocal
quality. I tried boosting the gain from the digital (ALSA driver) side
but this introduced an incredible amount of noise as expected. I then
approached this from the analog domain and tried alternative circuitry
to boost the sound quality based off this `carbon-to-dynamic
converter <https://www.circuits-diy.com/mic-converter-circuit/>`__.
Might be worth a further investigation in the future since it retains
the integrity of the original rotary phone.
My final attempt involved the introduction of some post-proceesing (see
dev branch) to bandpass some of the freqs outside the speech domain and
add some normalization. The processing was costly in terms of processing
and power consumption/rendering time and I ultimately decided it was
worth acquiring something that yielded a better capture right out the
gate. Crap in, crap out - as they say in the sound recording industry.
To replace:
- Unscrew mouthpiece and remove the carbon mic
- Pop out the plastic terminal housing with the two metal leads
- Unscrew red and black wires from terminal
- Prepare your lav mic
- I pulled off the 3.5mm male headphone pin since it is usually
coated and annoyingly difficult to solder directly on to.
- Carefully separate the two wires from the lav mic and spiral up
the surrounding copper. This will act as our ground signal.
- Extend the green wire from the phone cord clip to the ground point of
the lav mic.
- Red to red, black to blue as per the following diagram:
.. figure:: _static/images/phone_wiring.jpg
:alt: image
image
.. figure:: _static/images/handset_mic_wiring.jpg
:alt: image
image
.. figure:: _static/images/handset_mic_positioning.jpg
:alt: image
image
Software
--------
Dev Environment
~~~~~~~~~~~~~~~
- rpi image:
`Rasbian <https://www.raspberrypi.com/documentation/computers/getting-started.html>`__
w/ SSH enabled
- rpi on same network as development machine
- *Optional: vscode w/*\ `SSH FS
extension <https://marketplace.visualstudio.com/items?itemName=Kelvin.vscode-sshfs>`__
`Heres <https://jayproulx.medium.com/headless-raspberry-pi-zero-w-setup-with-ssh-and-wi-fi-8ddd8c4d2742>`__
a great guide to get the rpi setup headless w/ SSH & WiFi dialed in.
Installation
~~~~~~~~~~~~
- On the networked rpi - clone the repository:
.. code:: bash
git clone git@github.com:nickpourazima/rotary-phone-audio-guestbook.git
cd rotary-phone-audio-guestbook
- Next, use the installer script for a hassle-free setup.:
.. code:: bash
chmod +x installer.sh
./installer.sh
- Note, this script takes care of several tasks:
1. Install required dependencies.
2. Populate config.yaml based on user input
3. Replace placeholders in the service file to adapt to your project
directory.
4. Move the modified service file to the systemd directory.
5. Create necessary directories (recordings and sounds).
6. Grant execution permissions to the Python scripts.
7. Reload systemd, enable, and start the service.
`audioGuestBook systemctl service <audioGuestBook.service>`__
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This service ensures smooth operation without manual intervention every
time your Raspberry Pi boots up. The installer script will place this
service file in the ``/etc/systemd/system`` directory and modify paths
according to your project directory.
Manual control of the service is possible as it operates as any other
```.service``
entity <https://www.freedesktop.org/software/systemd/man/systemd.service.html>`__
`Config <config.yaml>`__
~~~~~~~~~~~~~~~~~~~~~~~~
- This file allows you to customize your own set up (edit rpi pins,
audio reduction, alsa mapping, etc), modify the yaml as necessary.
- Ensure the sample rate is supported by your audio interface (default
= 44100 Hz (decimal not required))
- For GPIO mapping, refer to the wiring diagram specific to your rpi:
|rpi|
- **hook_type**: Define your hook switch type here. Set it to “NC” if
your phone uses a Normally Closed hook switch or “NO” for Normally
Open.
`AudioInterface Class <audioInterface.py>`__
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Utilizes pydub and pyaudio extensively.
- Houses the main playback/record logic and has future #TODO expansion
for postprocessing the audio. Would like to test on an rpi4 to see if
it can handle it better for real-time applications.
Operation Mode 1: `audioGuestBook </audioGuestBook.py>`__
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- This is the main operation mode of the device.
- There are two callbacks in main which poll the gpio pins for the
specified activity (hook depressed, hook released).
- In the code, depending on the ``hook_type`` set in the
``config.yaml``, the software will adapt its behavior. For NC types,
hanging up the phone will trigger the ``on_hook`` behavior, and
lifting the phone will trigger the ``off_hook`` behavior. The
opposite will be true for NO types.
- Once triggered the appropriate function is called.
- On hook (depressed)
- Nothing happens
- Off hook (released)
- Plays back your own added welcome message located in
``/sounds/voicemail.wav`` followed by the
`beep </sounds/beep.wav>`__ indicating the start of recording.
- Begins recording the guests voice message.
- Guest hangs up, recording is stopped and stored to the
``/recordings/`` directory.
- If the guest exceeds the **recording_limit** specified in the
`config.yaml </config.yaml>`__, play the warning
`time_exceeded.wav </sounds/time_exceeded.wav>`__ sound and stop
recording.
Operation Mode 2: `audioGuestBookwithRotaryDialer <./todo/audioGuestBookwithRotaryDialer.py>`__
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Note:** Untested - decided not to go this route for my own wedding
- This mode is a special modification of the normal operation and
requires a slightly different wiring connection since it accepts
input from the rotary dialer.
- The idea was to playback special messages when particular users dial
a certain number combination (i.e. 909 would play back a message for
certain guests who lived with the groom in that area code).
- In this mode of operation the users will need to dial 0 on the rotary
dialer in order to initiate the voicemail.
- The rotary dialer is a bit more complex to set up, you need a pull up
resistor connected between the F screw terminal and 5V on the rpi and
the other end on GPIO 23. #TODO: Diagram
Troubleshooting
---------------
Configuring Hook Type
~~~~~~~~~~~~~~~~~~~~~
If you find that the behaviors for hanging up and lifting the phone are
reversed, its likely that the ``hook_type`` in ``config.yaml`` is
incorrectly set. Ensure that it matches your phones hook switch type
(NC or NO).
Verify default audio interface
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A few users had issues where audio I/O was defaulting to HDMI. To
alleviate this, check the following:
Check the Sound Card Configuration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Verify the available sound devices using the following command:
.. code:: bash
aplay -l
*Ensure that your USB audio interface is listed and note the card and
device numbers.*
Set the Default Sound Card
^^^^^^^^^^^^^^^^^^^^^^^^^^
If you want to route audio through your USB audio interface, youll need
to make it the default sound card. Edit the ALSA configuration file
(usually located at ``/etc/asound.conf`` or ``~/.asoundrc``) and add the
following:
.. code:: bash
defaults.pcm.card X
defaults.ctl.card X
*Replace X with the card number of your USB audio interface obtained
from the previous step.*
Restart ALSA
^^^^^^^^^^^^
.. code:: bash
sudo /etc/init.d/alsa-utils restart
Support
-------
If this code helped you or if you have some feedback, Id be thrilled to
`hear about it <mailto:dillpicholas@duck.com>`__! Feel like saying
thanks? You can `buy me a coffee <https://ko-fi.com/dillpicholas>`__\ ☕.
.. |hook1| image:: _static/images/hook_test_1.jpg
.. |hook2| image:: _static/images/hook_test_2.jpg
.. |term1| image:: _static/images/block_terminal.jpg
.. |term2| image:: _static/images/top_view_block_terminal.jpg
.. |rpi| image:: _static/images/rpi_GPIO.png

31
docs/conf.py Normal file
View File

@ -0,0 +1,31 @@
import os
import sys
sys.path.insert(0, os.path.abspath('../'))
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Rotary Phone Audio Guestbook'
copyright = '2023, Nick Pourazima'
author = 'Nick Pourazima'
release = 'v1.0.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = ['sphinx.ext.autodoc']
autodoc_typehints = 'description'
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'furo'
html_static_path = ['_static']

19
docs/index.rst Normal file
View File

@ -0,0 +1,19 @@
Welcome to Rotary Phone Audio Guestbook's documentation!
========================================================
This documentation covers the Rotary Phone Audio Guestbook project.
.. toctree::
:maxdepth: 2
:caption: Contents:
README
module_audioGuestBook
module_audioInterface
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

35
docs/make.bat Normal file
View File

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

View File

@ -0,0 +1,7 @@
Audio Guest Book Module
=======================
This module contains the main script for the Rotary Phone Audio Guestbook.
.. automodule:: src.audioGuestBook
:members:

View File

@ -0,0 +1,9 @@
Audio Interface Class
=====================
The `AudioInterface` class is responsible for handling audio recording and playback functionalities.
.. autoclass:: src.audioInterface.AudioInterface
:members:
:undoc-members:
:show-inheritance:

View File

@ -121,7 +121,6 @@ rotary_gpio: 23
rotary_hold_repeat: true
rotary_hold_time: 0.25
sample_rate: $sample_rate
source_file: audioGuestBook.py
format: $format
hook_type: $hook_type
EOF
@ -145,7 +144,7 @@ if ! sudo mkdir -p "$DIR/recordings"; then
fi
# Set execution permissions for the main script
if ! sudo chmod +x "$DIR/audioGuestBook.py"; then
if ! sudo chmod +x "$DIR/src/audioGuestBook.py"; then
echo "Failed to set script permissions."
exit 1
fi

View File

@ -11,7 +11,7 @@ import yaml
from gpiozero import Button
from pydub import AudioSegment, playback
import audioInterface
import src.audioInterface as audioInterface
# Set up logging
logging.basicConfig(level=logging.INFO)
@ -27,6 +27,15 @@ FORMATS = {
def load_config():
"""
Loads the configuration from a YAML file.
Returns:
dict: Configuration dictionary.
Raises:
SystemExit: If the configuration file is not found.
"""
try:
with CONFIG_PATH.open() as f:
return yaml.safe_load(f)
@ -38,6 +47,13 @@ def load_config():
def play_audio(filename, reduction=0):
"""
Plays an audio file with the option to reduce its volume.
Args:
filename (str): The name of the audio file to play.
reduction (int): The amount of volume reduction (default is 0).
"""
try:
sound_path = BASE_DIR / "sounds" / filename
sound = AudioSegment.from_wav(sound_path) - reduction
@ -47,6 +63,12 @@ def play_audio(filename, reduction=0):
def off_hook():
"""
Handles the off-hook event.
Initializes the audio interface, plays the voicemail and beep sounds,
and starts recording the audio.
"""
global hook, config
logger.info("Phone off hook, ready to begin!")
@ -61,14 +83,18 @@ def off_hook():
dev_index=config["alsa_hw_mapping"],
hook_type=config["hook_type"],
)
# Explicitly initialize audio resources
audio_interface.init_audio()
# Playing pre-recorded messages before recording
logger.info("Playing voicemail message...")
play_audio("voicemail.wav", config["playback_reduction"])
logger.info("Playing beep...")
play_audio("beep.wav", config["beep_reduction"])
logger.info("recording")
# Start recording
logger.info("Recording")
audio_interface.record()
audio_interface.stop()
@ -78,12 +104,25 @@ def off_hook():
def on_hook():
"""
Handles the on-hook event.
Logs a message indicating that the phone is on hook.
"""
logger.info("Phone on hook.\nSleeping...")
def main():
"""
The main function of the script.
Initializes the system, loads configuration, and sets up hook events.
"""
global config, hook
logger.info("Remember to monitor system resources during recording.")
config = load_config()
# Setting up the hook based on configuration
if config["hook_type"] == "NC":
hook = Button(config["hook_gpio"], pull_up=True)
hook.when_pressed = on_hook

294
src/audioInterface.py Normal file
View File

@ -0,0 +1,294 @@
#! /usr/bin/env python3
import logging
import time
import wave
from typing import List
import pyaudio
from pydub import AudioSegment
from pydub.effects import compress_dynamic_range, normalize
from pydub.scipy_effects import band_pass_filter
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class AudioInterface:
"""
A class to handle audio recording and playback functionalities.
:param chunk: The size of each audio chunk to be read or written.
:type chunk: int
:param chans: Number of audio channels.
:type chans: int
:param format: The format of the audio, e.g., pyaudio.paInt16.
:type format: int
:param frames: List to store frame bytes of the recorded audio.
:type frames: List[bytes]
:param hook: GPIO Button object to detect on and off-hook events.
:type hook: Button object
:param samp_rate: The sample rate of the audio.
:type samp_rate: int
:param recording_limit: Maximum recording duration in seconds.
:type recording_limit: int
:param dev_index: Index of the audio device to use.
:type dev_index: int
:param hook_type: Type of the hook (NC - Normally Closed, NO - Normally Open).
:type hook_type: str
:param filter_low_freq: Lower frequency for band-pass filter.
:type filter_low_freq: int
:param filter_high_freq: Higher frequency for band-pass filter.
:type filter_high_freq: int
:param audio: PyAudio object for audio operations.
:type audio: PyAudio object
:param stream: Audio stream for recording or playback.
:type stream: Audio
"""
def __init__(
self,
hook,
buffer_size,
channels,
format,
sample_rate,
recording_limit,
dev_index,
hook_type,
filter_low_freq=300,
filter_high_freq=10000,
) -> None:
"""
Initializes the audio interface with the specified configuration.
Args:
hook: GPIO Button object for hook detection.
buffer_size (int): Size of each audio buffer chunk.
channels (int): Number of audio channels.
format (int): Audio format (e.g., pyaudio.paInt16).
sample_rate (int): Audio sample rate.
recording_limit (int): Maximum recording time in seconds.
dev_index (int): Index of the audio device.
hook_type (str): Type of the hook (NC or NO).
filter_low_freq (int): Lower frequency for band-pass filter.
filter_high_freq (int): Higher frequency for band-pass filter.
"""
# Audio configuration
self.chunk = buffer_size
self.chans = channels
self.format = format
self.frames = []
self.hook = hook
self.samp_rate = sample_rate
self.recording_limit = recording_limit
self.dev_index = dev_index
self.hook_type = hook_type
self.filter_low_freq = filter_low_freq
self.filter_high_freq = filter_high_freq
# Audio resources
self.audio = None
self.stream = None
logger.info(
f"Initializing Audio Interface with sample rate: {sample_rate}, format: {format}"
)
def init_audio(self):
"""
Initializes (or reinitializes) the audio resources for recording.
Closes any existing stream and PyAudio instance before re-creating them.
"""
# Closing existing stream if open
if self.stream is not None:
self.stream.stop_stream()
self.stream.close()
self.stream = None
# Terminating existing PyAudio instance if it exists
if self.audio is not None:
self.audio.terminate()
# Creating new PyAudio instance and resetting frame list
self.audio = pyaudio.PyAudio()
self.frames = []
logger.info("Audio resources initialized.")
def record(self):
"""
Records audio until the off-hook condition is false or the recording limit is reached.
This method initializes the audio stream and reads audio chunks in a loop, appending them to the frame list.
If the recording time exceeds the set limit, a 'time exceeded' notification is played.
"""
self.init_audio()
logger.info("Audio stream initialized for recording.")
self.stream = self.audio.open(
format=self.format,
rate=self.samp_rate,
channels=self.chans,
input_device_index=self.dev_index,
input=True,
frames_per_buffer=self.chunk,
)
# loop through stream and append audio chunks to frame array
try:
start = time.time()
while self.off_hook_condition():
if time.time() - start < self.recording_limit:
data = self.stream.read(self.chunk, exception_on_overflow=True)
self.frames.append(data)
else:
# Notify the user that their recording time is up
self.play("time_exceeded.wav")
break
except KeyboardInterrupt:
logger.info("Done recording")
except Exception as e:
logger.error(f"Recording error: {e}")
def off_hook_condition(self):
"""
Determines the off-hook condition based on the hook type.
Returns:
bool: True if the off-hook condition is met, False otherwise.
"""
return (
not self.hook.is_pressed if self.hook_type == "NC" else self.hook.is_pressed
)
def play(self, file):
"""
Plays an audio file.
This method initializes the audio resources and plays the specified audio file.
Args:
file (str): The path to the audio file to be played.
Raises:
FileNotFoundError: If the specified audio file does not exist.
wave.Error: If there is an error processing the wave file.
"""
try:
self.init_audio()
with wave.open(file, "rb") as wf:
self.stream = self.audio.open(
format=self.audio.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=wf.getframerate(),
output=True,
)
data = wf.readframes(self.chunk)
while data:
self.stream.write(data)
data = wf.readframes(self.chunk)
except FileNotFoundError:
logger.error(f"File not found: {file}")
except wave.Error as e:
logger.error(f"Wave error: {e}")
finally:
if self.stream:
self.stream.stop_stream()
self.stream.close()
def stop(self):
"""
Stops the audio stream and terminates the PyAudio session.
This method is used to cleanly stop audio playback or recording and release resources.
"""
if self.stream:
logger.info("Stopping audio stream.")
self.stream.stop_stream()
self.stream.close()
if self.audio:
logger.info("Terminating PyAudio session.")
self.audio.terminate()
def close(self, output_file):
"""
Closes the audio interface and saves the recorded frames to a file.
Args:
output_file (str): The path to the output file where the recording will be saved.
Raises:
OSError: If there is an error writing the audio data to the file.
"""
try:
with wave.open(output_file, "wb") as wavefile:
wavefile.setnchannels(self.chans)
wavefile.setsampwidth(self.audio.get_sample_size(self.format))
wavefile.setframerate(self.samp_rate)
wavefile.writeframes(b"".join(self.frames))
logger.info(f"Recording saved to {output_file}")
except OSError as e:
logger.error(f"Error writing to file {output_file}. Error: {e}")
def post_process(self, output_file):
"""
Applies post-processing to the recorded audio and saves the processed files.
The post-processing includes filtering, normalization, and dynamic range compression.
The processed audio is saved in both WAV and MP3 formats.
Args:
output_file (str): The base path for the output files.
Raises:
Exception: If there is an error during post-processing.
"""
try:
source = AudioSegment.from_wav(output_file + ".wav")
filtered = self.filter_audio(source)
normalized = self.normalize_audio(filtered)
compressed = self.compress_audio(normalized)
normalized.export(output_file + "normalized.wav", format="wav")
compressed.export(output_file + "compressed.mp3", format="mp3")
logger.info("Post-processing completed successfully.")
except Exception as e:
logger.error(f"Post-processing error: {e}")
def filter_audio(self, audio):
"""
Applies a band-pass filter to the given audio.
Args:
audio (AudioSegment): The audio segment to be filtered.
Returns:
AudioSegment: The filtered audio segment.
"""
logger.info("Filtering audio.")
return band_pass_filter(audio, self.filter_low_freq, self.filter_high_freq)
def normalize_audio(self, audio):
"""
Normalizes the given audio segment.
Args:
audio (AudioSegment): The audio segment to be normalized.
Returns:
AudioSegment: The normalized audio segment.
"""
logger.info("Normalizing audio.")
return normalize(audio)
def compress_audio(self, audio):
"""
Compresses the dynamic range of the given audio segment.
Args:
audio (AudioSegment): The audio segment to be compressed.
Returns:
AudioSegment: The audio segment with compressed dynamic range.
"""
logger.info("Compressing dynamic range of audio.")
return compress_dynamic_range(audio)

View File

@ -1,6 +1,6 @@
#! /usr/bin/env python3
import audioInterface
import src.audioInterface as audioInterface
import os
import yaml
import sys