add sphinx docs, comments, safety features
This commit is contained in:
parent
ccb48d89ec
commit
e787bde452
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
*.code*
|
*.code*
|
||||||
*.trunk*
|
*.trunk*
|
||||||
|
__pycache__
|
@ -6,7 +6,7 @@ After=multi-user.target
|
|||||||
WorkingDirectory=<path-to-project>
|
WorkingDirectory=<path-to-project>
|
||||||
Type=simple
|
Type=simple
|
||||||
Restart=always
|
Restart=always
|
||||||
ExecStart=/usr/bin/env python3 <path-to-project>/audioGuestBook.py
|
ExecStart=/usr/bin/env python3 <path-to-project>/src/audioGuestBook.py
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
@ -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)
|
|
@ -9,6 +9,5 @@ rotary_gpio: 23
|
|||||||
rotary_hold_repeat: true
|
rotary_hold_repeat: true
|
||||||
rotary_hold_time: 0.25
|
rotary_hold_time: 0.25
|
||||||
sample_rate: 44100
|
sample_rate: 44100
|
||||||
source_file: audioGuestBook.py
|
|
||||||
format: INT16
|
format: INT16
|
||||||
hook_type: NC # or 'NO'
|
hook_type: NC # or 'NO'
|
2
docs/.gitignore
vendored
Normal file
2
docs/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
_build
|
||||||
|
_static/images
|
20
docs/Makefile
Normal file
20
docs/Makefile
Normal 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
548
docs/README.rst
Normal 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, I’ve detailed a guide
|
||||||
|
on creating your own audio guestbook. If you have questions, don’t
|
||||||
|
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 you’re interested in
|
||||||
|
expanding on this, you’re 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 didn’t 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 don’t 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 you’d | 1 | $ |
|
||||||
|
| .amazon.com/dp/B01N6P80OQ?ref=nb_sb_ss_w | like to replace the | | 2 |
|
||||||
|
| _as-reorder-t1_ypp_rep_k3_1_9&=&crid= | carbon microphone. | | 4 |
|
||||||
|
| 15WZEWMZ17EM9&=&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, you’ll 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>`__.
|
||||||
|
- `Here’s <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>`__
|
||||||
|
|
||||||
|
`Here’s <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, it’s likely that the ``hook_type`` in ``config.yaml`` is
|
||||||
|
incorrectly set. Ensure that it matches your phone’s 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, you’ll 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, I’d 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
31
docs/conf.py
Normal 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
19
docs/index.rst
Normal 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
35
docs/make.bat
Normal 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
|
7
docs/module_audioGuestBook.rst
Normal file
7
docs/module_audioGuestBook.rst
Normal 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:
|
9
docs/module_audioInterface.rst
Normal file
9
docs/module_audioInterface.rst
Normal 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:
|
@ -121,7 +121,6 @@ rotary_gpio: 23
|
|||||||
rotary_hold_repeat: true
|
rotary_hold_repeat: true
|
||||||
rotary_hold_time: 0.25
|
rotary_hold_time: 0.25
|
||||||
sample_rate: $sample_rate
|
sample_rate: $sample_rate
|
||||||
source_file: audioGuestBook.py
|
|
||||||
format: $format
|
format: $format
|
||||||
hook_type: $hook_type
|
hook_type: $hook_type
|
||||||
EOF
|
EOF
|
||||||
@ -145,7 +144,7 @@ if ! sudo mkdir -p "$DIR/recordings"; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Set execution permissions for the main script
|
# 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."
|
echo "Failed to set script permissions."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
@ -11,7 +11,7 @@ import yaml
|
|||||||
from gpiozero import Button
|
from gpiozero import Button
|
||||||
from pydub import AudioSegment, playback
|
from pydub import AudioSegment, playback
|
||||||
|
|
||||||
import audioInterface
|
import src.audioInterface as audioInterface
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@ -27,6 +27,15 @@ FORMATS = {
|
|||||||
|
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
|
"""
|
||||||
|
Loads the configuration from a YAML file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Configuration dictionary.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If the configuration file is not found.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with CONFIG_PATH.open() as f:
|
with CONFIG_PATH.open() as f:
|
||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
@ -38,6 +47,13 @@ def load_config():
|
|||||||
|
|
||||||
|
|
||||||
def play_audio(filename, reduction=0):
|
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:
|
try:
|
||||||
sound_path = BASE_DIR / "sounds" / filename
|
sound_path = BASE_DIR / "sounds" / filename
|
||||||
sound = AudioSegment.from_wav(sound_path) - reduction
|
sound = AudioSegment.from_wav(sound_path) - reduction
|
||||||
@ -47,6 +63,12 @@ def play_audio(filename, reduction=0):
|
|||||||
|
|
||||||
|
|
||||||
def off_hook():
|
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
|
global hook, config
|
||||||
|
|
||||||
logger.info("Phone off hook, ready to begin!")
|
logger.info("Phone off hook, ready to begin!")
|
||||||
@ -61,14 +83,18 @@ def off_hook():
|
|||||||
dev_index=config["alsa_hw_mapping"],
|
dev_index=config["alsa_hw_mapping"],
|
||||||
hook_type=config["hook_type"],
|
hook_type=config["hook_type"],
|
||||||
)
|
)
|
||||||
|
# Explicitly initialize audio resources
|
||||||
|
audio_interface.init_audio()
|
||||||
|
|
||||||
|
# Playing pre-recorded messages before recording
|
||||||
logger.info("Playing voicemail message...")
|
logger.info("Playing voicemail message...")
|
||||||
play_audio("voicemail.wav", config["playback_reduction"])
|
play_audio("voicemail.wav", config["playback_reduction"])
|
||||||
|
|
||||||
logger.info("Playing beep...")
|
logger.info("Playing beep...")
|
||||||
play_audio("beep.wav", config["beep_reduction"])
|
play_audio("beep.wav", config["beep_reduction"])
|
||||||
|
|
||||||
logger.info("recording")
|
# Start recording
|
||||||
|
logger.info("Recording")
|
||||||
audio_interface.record()
|
audio_interface.record()
|
||||||
audio_interface.stop()
|
audio_interface.stop()
|
||||||
|
|
||||||
@ -78,12 +104,25 @@ def off_hook():
|
|||||||
|
|
||||||
|
|
||||||
def on_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...")
|
logger.info("Phone on hook.\nSleeping...")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
"""
|
||||||
|
The main function of the script.
|
||||||
|
|
||||||
|
Initializes the system, loads configuration, and sets up hook events.
|
||||||
|
"""
|
||||||
global config, hook
|
global config, hook
|
||||||
|
logger.info("Remember to monitor system resources during recording.")
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
|
# Setting up the hook based on configuration
|
||||||
if config["hook_type"] == "NC":
|
if config["hook_type"] == "NC":
|
||||||
hook = Button(config["hook_gpio"], pull_up=True)
|
hook = Button(config["hook_gpio"], pull_up=True)
|
||||||
hook.when_pressed = on_hook
|
hook.when_pressed = on_hook
|
294
src/audioInterface.py
Normal file
294
src/audioInterface.py
Normal 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)
|
@ -1,6 +1,6 @@
|
|||||||
#! /usr/bin/env python3
|
#! /usr/bin/env python3
|
||||||
|
|
||||||
import audioInterface
|
import src.audioInterface as audioInterface
|
||||||
import os
|
import os
|
||||||
import yaml
|
import yaml
|
||||||
import sys
|
import sys
|
Loading…
Reference in New Issue
Block a user