Last active
January 10, 2023 22:23
-
-
Save mattpitkin/242a6e39e65775e172ac4379695f28d6 to your computer and use it in GitHub Desktop.
DTMF keypad
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
GUI keypad that plays DTMF (Dual-tone multi-frequency) signals. | |
https://en.wikipedia.org/wiki/Dual-tone_multi-frequency_signaling | |
""" | |
import sys | |
from functools import partial | |
from string import ascii_uppercase | |
from threading import Thread, Event | |
import pyaudio | |
import numpy as np | |
from PyQt5.QtWidgets import QApplication, QGridLayout, QPushButton, QWidget | |
from PyQt5.QtGui import QFont | |
from PyQt5.QtCore import Qt | |
# map between keyboard values and button text | |
KEYS = { | |
int(Qt.Key_0): "0", | |
int(Qt.Key_1): "1", | |
int(Qt.Key_2): "2", | |
int(Qt.Key_3): "3", | |
int(Qt.Key_4): "4", | |
int(Qt.Key_5): "5", | |
int(Qt.Key_6): "6", | |
int(Qt.Key_7): "7", | |
int(Qt.Key_8): "8", | |
int(Qt.Key_9): "9", | |
int(Qt.Key_A): "A", | |
int(Qt.Key_B): "B", | |
int(Qt.Key_C): "C", | |
int(Qt.Key_D): "D", | |
int(Qt.Key_Asterisk): "*", | |
int(Qt.Key_NumberSign): "#", | |
} | |
# map between button text and dual tone frequencies | |
FREQUENCIES = { | |
"1": [697, 1209], | |
"2": [697, 1336], | |
"3": [697, 1477], | |
"A": [697, 1633], | |
"4": [770, 1209], | |
"5": [770, 1336], | |
"6": [770, 1477], | |
"B": [770, 1633], | |
"7": [852, 1209], | |
"8": [852, 1336], | |
"9": [852, 1477], | |
"C": [852, 1633], | |
"*": [941, 1209], | |
"0": [941, 1336], | |
"#": [941, 1477], | |
"D": [941, 1633], | |
} | |
SR = 44100 # output sample rate | |
class KeyPad(QWidget): | |
def __init__(self): | |
super().__init__() | |
self.setWindowTitle("DTMF keypad") | |
# grid layout | |
self._layout = QGridLayout() | |
self.keys_pressed = [] | |
self.add_buttons() | |
self.setLayout(self._layout) | |
# open audio output | |
self._pa = pyaudio.PyAudio() | |
self._chunk = 1024 | |
self._playing = Event() | |
self._playing.clear() | |
self.show() | |
def add_buttons(self): | |
# add buttons to keypad | |
self.buttons = {} | |
self.buttons["1"] = QPushButton("1") | |
self.buttons["2"] = QPushButton("2") | |
self.buttons["3"] = QPushButton("3") | |
self.buttons["A"] = QPushButton("A") | |
self.buttons["4"] = QPushButton("4") | |
self.buttons["5"] = QPushButton("5") | |
self.buttons["6"] = QPushButton("6") | |
self.buttons["B"] = QPushButton("B") | |
self.buttons["7"] = QPushButton("7") | |
self.buttons["8"] = QPushButton("8") | |
self.buttons["9"] = QPushButton("9") | |
self.buttons["C"] = QPushButton("C") | |
self.buttons["*"] = QPushButton("*") | |
self.buttons["0"] = QPushButton("0") | |
self.buttons["#"] = QPushButton("#") | |
self.buttons["D"] = QPushButton("D") | |
# key pad shape | |
shape = (4, 4) | |
# list of current tone frequencies to play | |
self._freqs = [] | |
for i, key in enumerate(self.buttons): | |
self.buttons[key].setFixedSize(64, 64) | |
self.buttons[key].setFont(QFont("Arial", 24, QFont.Bold)) | |
if key in ascii_uppercase: | |
self.buttons[key].setStyleSheet("background-color: #ff0000") | |
elif not key.isdigit(): | |
self.buttons[key].setStyleSheet("background-color: #00ff00") | |
# add frequencies on button click | |
self.buttons[key].pressed.connect(partial(self._add_freqs, FREQUENCIES[key])) | |
# remove frequencies when button released | |
self.buttons[key].released.connect(partial(self._remove_freqs, FREQUENCIES[key])) | |
pos = np.unravel_index(i, shape) | |
self._layout.addWidget(self.buttons[key], pos[0], pos[1]) | |
def _add_freqs(self, freqs): | |
""" | |
Add tone frequencies. | |
""" | |
start_audio = False | |
if len(self._freqs) == 0: | |
start_audio = True | |
self._freqs.append(freqs) | |
if start_audio: | |
# start audio output thread | |
self._playing.set() | |
# open audio stream | |
self._stream = self._pa.open( | |
format=pyaudio.paFloat32, # 32-bit float | |
channels=1, # mono channel | |
rate=SR, | |
output=True, | |
frames_per_buffer=self._chunk, | |
) | |
self._playing_thread = Thread(target=self.play_tones, daemon=True) | |
self._playing_thread.start() | |
def _remove_freqs(self, freqs): | |
""" | |
Remove tone frequencies. | |
""" | |
self._freqs.remove(freqs) | |
if len(self._freqs) == 0: | |
# end audio output thread | |
self._playing.clear() | |
def keyPressEvent(self, event): | |
if not event.isAutoRepeat(): | |
for key in KEYS: | |
if int(event.key()) == key: | |
if FREQUENCIES[KEYS[key]] not in self._freqs: | |
self._add_freqs(FREQUENCIES[KEYS[key]]) | |
self.buttons[KEYS[key]].setDown(True) | |
break | |
def keyReleaseEvent(self, event): | |
if not event.isAutoRepeat(): | |
for key in KEYS: | |
if int(event.key()) == key: | |
if FREQUENCIES[KEYS[key]] in self._freqs: | |
self._remove_freqs(FREQUENCIES[KEYS[key]]) | |
self.buttons[KEYS[key]].setDown(False) | |
break | |
def closeEvent(self, event): | |
# stop thread and close pyAudio stream | |
self._playing.clear() | |
try: | |
self._stream.close() | |
except: | |
pass | |
self._pa.terminate() | |
def play_tones(self): | |
t0 = 0 | |
dt = 1 / SR | |
deltat = self._chunk * dt | |
amp = 1 / 16 # set amplitude so that total cannot go above 1 | |
while self._playing.is_set(): | |
times = np.arange(t0, t0 + deltat, dt) | |
tones = np.zeros_like(times, dtype=np.float32) | |
for f in np.unique(np.array(self._freqs).flatten()): | |
tones += (amp * np.sin(2 * np.pi * f * times)).astype(np.float32) | |
self._stream.write(tones.tobytes()) | |
t0 += deltat | |
self._stream.close() | |
app = QApplication([]) | |
app.setStyle("Fusion") # explicitly use "Fusion" style sheet rather than default https://stackoverflow.com/a/75075364/1862861 | |
keypad = KeyPad() | |
sys.exit(app.exec()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment