Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
ba2ed75396 | ||
|
7218e59331 | ||
|
e787bde452 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
*.code*
|
||||
*.trunk*
|
||||
__pycache__
|
@ -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
|
@ -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_time: 0.25
|
||||
sample_rate: 44100
|
||||
source_file: audioGuestBook.py
|
||||
format: INT16
|
||||
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:
|
95
installer.sh
95
installer.sh
@ -1,18 +1,88 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Dependency installation
|
||||
echo "Installing dependencies..."
|
||||
sudo apt-get update
|
||||
if ! sudo apt-get install -y python3-pip python3-gpiozero; then
|
||||
echo "Failed to install system packages."
|
||||
exit 1
|
||||
fi
|
||||
# Rotary Phone Audio Guestbook Installer
|
||||
|
||||
# Use --user flag for pip installations
|
||||
if ! pip3 install --user pydub pyaudio PyYAML; then
|
||||
echo "Failed to install Python packages."
|
||||
echo "Starting the installation process..."
|
||||
|
||||
# Update and install system dependencies
|
||||
echo "Installing additional dependencies..."
|
||||
sudo apt-get install -y python3-pip python3-venv python3-gpiozero ffmpeg || {
|
||||
echo "Failed to install required system packages."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Set up Python virtual environment for project dependencies
|
||||
echo "Setting up Python virtual environment..."
|
||||
python3 -m venv ~/rotary-phone-venv || {
|
||||
echo "Failed to create Python virtual environment."
|
||||
exit 1
|
||||
}
|
||||
source ~/rotary-phone-venv/bin/activate
|
||||
|
||||
# Install Python dependencies in the virtual environment
|
||||
pip install pydub pyaudio PyYAML sounddevice || {
|
||||
echo "Failed to install Python dependencies."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Modify PulseAudio configuration for improved audio handling
|
||||
echo "Configuring PulseAudio..."
|
||||
sudo cp /etc/pulse/default.pa /etc/pulse/default.pa.backup
|
||||
echo -e "default-fragments = 5\ndefault-fragment-size-msec = 2" | sudo tee -a /etc/pulse/default.pa
|
||||
|
||||
# Restart PulseAudio to apply changes
|
||||
pulseaudio -k
|
||||
pulseaudio --start
|
||||
|
||||
# Display available sound cards and devices
|
||||
echo "Listing available sound cards and devices:"
|
||||
aplay -l
|
||||
|
||||
# Prompt user for ALSA configuration values
|
||||
echo "Configuring ALSA..."
|
||||
read -p "Enter the card number for the default playback card (e.g., 0, 1): " playback_card
|
||||
read -p "Enter the card number for the default capture card (e.g., 0, 1): " capture_card
|
||||
read -p "Enter the default sample rate (e.g., 44100): " sample_rate
|
||||
while ! [[ "$sample_rate" =~ ^[89][0-9]{3}$|^[1-9][0-9]{4}$|^[1][0-8][0-9]{4}$|192000$ ]]; do
|
||||
echo "Invalid sample rate. Please enter a value between 8000 and 192000."
|
||||
read -p "Enter the default sample rate (e.g., 44100): " sample_rate
|
||||
done
|
||||
|
||||
read -p "Enter the bit depth (16, 24, 32): " bit_depth
|
||||
while ! [[ "$bit_depth" =~ ^(16|24|32)$ ]]; do
|
||||
echo "Invalid bit depth. Please choose from 16, 24, or 32."
|
||||
read -p "Enter the bit depth (16, 24, 32): " bit_depth
|
||||
done
|
||||
|
||||
# Write ALSA configuration
|
||||
echo "Applying ALSA configuration..."
|
||||
sudo tee /etc/asound.conf >/dev/null <<EOF
|
||||
# Custom ALSA configuration for Rotary Phone Audio Guestbook
|
||||
defaults.pcm.rate_converter "samplerate"
|
||||
defaults.pcm.dmix.rate $sample_rate
|
||||
defaults.pcm.dmix.format S$bit_depth
|
||||
defaults.ctl.card $playback_card
|
||||
defaults.pcm.card $playback_card
|
||||
defaults.pcm.device 0
|
||||
defaults.pcm.subdevice -1
|
||||
defaults.pcm.nonblock 1
|
||||
defaults.pcm.compat 0
|
||||
pcm.!default {
|
||||
type hw
|
||||
card $playback_card
|
||||
}
|
||||
ctl.!default {
|
||||
type hw
|
||||
card $capture_card
|
||||
}
|
||||
EOF
|
||||
|
||||
# Test recording and playback functionality
|
||||
echo "Testing recording and playback..."
|
||||
arecord -D hw:$capture_card,0 -d 5 -f cd test-mic.wav && aplay test-mic.wav || {
|
||||
echo "Test failed. Check your microphone and speaker setup."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Get the directory of the currently executing script
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@ -121,7 +191,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 +214,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
|
||||
|
@ -11,7 +11,7 @@ import yaml
|
||||
from gpiozero import Button
|
||||
from pydub import AudioSegment, playback
|
||||
|
||||
import audioInterface
|
||||
import 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
|
226
src/audioInterface.py
Normal file
226
src/audioInterface.py
Normal file
@ -0,0 +1,226 @@
|
||||
#! /usr/bin/env python3
|
||||
|
||||
import logging
|
||||
import time
|
||||
import wave
|
||||
|
||||
import pyaudio
|
||||
|
||||
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=False)
|
||||
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}")
|
74
src/audioProcessing.py
Normal file
74
src/audioProcessing.py
Normal file
@ -0,0 +1,74 @@
|
||||
import logging
|
||||
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 AudioProcessing:
|
||||
|
||||
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
|
||||
|
||||
import audioInterface
|
||||
import src.audioInterface as audioInterface
|
||||
import os
|
||||
import yaml
|
||||
import sys
|
Loading…
Reference in New Issue
Block a user