Connect a vintage handset (just the speaking + listening part!) to a Raspberry Pi or Wemos D1. Read dialed numbers. Talk to ChatGPT. No original parts damaged.
Raspberry Pi 4/Zero 2 W runs a Python script. Dial a number, speak, and have a real conversation with ChatGPT via OpenAI's Whisper + TTS APIs. Full conversation memory.
ESP8266 Wemos D1 Mini + DFPlayer Mini MP3 module. Dial a number โ play a local MP3 (song, voice message, sound effect). Fully offline. Great for music players or dementia care.
Old rotary dials don't send a digital signal โ they use pulse dialing. When you dial a number, the mechanism temporarily breaks the circuit in rapid bursts. Your microcontroller counts those pulses to reconstruct the digit.
Pulses are typically sent at 10 pulses/second. Between digits there's a longer pause (~300ms+). Your code detects the pause to know a full digit has been dialed.
The rotary dial has 2โ4 wires. Two of them carry the pulse signal. To find them:
Always handle this edge case in your code: if pulse_count == 10, the digit dialed is 0, not 10.
The hook (the fork under the handset) presses a button when the handset is resting on it. When you pick up the handset, the button is released. This is how your script knows whether the phone is in use or idle. It connects to a GPIO pin and acts as a simple on/off trigger.
ROTARY PHONE INTERNALS โ What's happening electrically โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ HOOK SWITCH โ โ Handset on hook โ button PRESSED โ circuit LOW โ โ Handset lifted โ button OPEN โ circuit HIGH โ โ โ โ ROTARY DIAL โ โ Idle โ circuit CLOSED (continuous) โ โ Dialing โ circuit OPENS briefly, once per โ โ pulse. Count the opens = digit. โ โ โ โ Pulse timing: โ โ โพโพ|_|โพ|_|โพ|_|โพโพโพโพโพโพโพโพโพ โ dialing "3" โ โ pulse pulse pulse โ 3 pulses = digit 3 โ โ ^gap = end of digit โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Based on the open-source project by Pollux Labs (MIT License). A Python script monitors the hook switch and rotary dial, uses OpenAI Whisper for speech-to-text and TTS for the voice response.
Full source at polluxlabs.io by Frederik Kumbartzki. MIT Licensed.
| Component | Notes | Est. Cost |
|---|---|---|
| Raspberry Pi Zero 2 W or Pi 4 | Pi Zero 2 W fits inside the phone casing | ~โฌ18โ45 |
| MicroSD card (16GB+) | For Raspberry Pi OS Lite (64-bit) | ~โฌ5 |
| USB microphone (lavalier) | Sennheiser XS-Lav or any budget USB mic. Goes inside housing, not the handset. | ~โฌ10โ30 |
| 3.5mm mono/stereo audio cable | ~10cm section; for connecting RPi line-out to handset speaker | ~โฌ2 |
| 2.8mm flat connectors (ร2) | These plug into the handset socket โ check your phone's connector size | ~โฌ2 |
| Tactile push button | Small enough to fit under the hook fork mechanism | ~โฌ1 |
| Jumper cables (20cm) | For connecting button and dial to GPIO header | ~โฌ2 |
| OpenAI API key | Needs Whisper (STT), GPT-4o-mini (LLM), TTS. ~โฌ0.01โ0.05 per conversation | Pay-as-go |
RPi GPIO Component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ GPIO17 (Pin 11) โโ Hook button (one leg) GND (Pin 9) โโ Hook button (other leg) โ internal pull-up used โ no resistor needed GPIO23 (Pin 16) โโ Rotary dial (pulse wire) GND (Pin 14) โโ Rotary dial (ground wire) 3.5mm Line Out โโ 2.8mm flat connectors โ Handset speaker socket (use GND wire + one channel from the jack) USB Mic โโ USB-A port on Raspberry Pi (lavalier inside housing, near a ventilation slit)
Use Raspberry Pi Imager. Choose Raspberry Pi OS Lite (64-bit) under "Raspberry Pi OS (other)". In Edit Settings: set username/password, configure Wi-Fi, and enable SSH under the Services tab.
ssh pi@raspberrypi.local sudo apt update && sudo apt upgrade -y sudo apt install -y python3-pip python3-venv ffmpeg python3-gpiozero python3-rpi.gpio portaudio19-dev
mkdir -p ~/callGPT && cd ~/callGPT python3 -m venv venv source venv/bin/activate pip install openai python-dotenv pygame pyaudio numpy wave gpiozero RPi.GPIO lgpio gtts
# 440Hz dial tone (3 seconds) ffmpeg -f lavfi -i "sine=frequency=440:duration=3" -c:a libmp3lame a440.mp3 # Voice error messages python -c "from gtts import gTTS; gTTS('Please try again', lang='en').save('tryagain.mp3')" python -c "from gtts import gTTS; gTTS('Sorry, an error occurred', lang='en').save('error.mp3')"
echo "OPENAI_API_KEY=sk-your-key-here" > .env
Never put the key directly in your script. The .env file is loaded by python-dotenv.
Create callGPT.py with nano callGPT.py. Here's the architecture and key configuration sections:
# โโ GPIO pin assignments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ DIAL_PIN = 23 # GPIO23 (Pin 16) โ rotary dial pulse wire SWITCH_PIN = 17 # GPIO17 (Pin 11) โ hook switch button # โโ Audio detection tuning โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ SILENCE_THRESHOLD = 500 # Volume level to detect speech (raise if noisy room) MAX_SILENCE_CHUNKS = 20 # ~1.3 sec silence = end of sentence DEBOUNCE_TIME = 0.1 # 100ms debounce for pulse counting
def _pulse_detected(self): """Called every time the dial breaks the circuit (one pulse).""" if not self.switch.is_pressed: # Only count if handset is lifted current_time = time.time() if current_time - self.last_pulse_time > DEBOUNCE_TIME: self.pulse_count += 1 self.last_pulse_time = current_time def _check_number(self): """Called in main loop. Waits 1.5s after last pulse to commit digit.""" if not self.switch.is_pressed and self.pulse_count > 0: self.audio_manager.stop_continuous_tone() time.sleep(1.5) # Wait for digit to complete dialed = self.pulse_count if self.pulse_count != 10 else 0 # 0 = 10 pulses! print(f"Dialed: {dialed}") if dialed == 1: self._call_gpt_service() # 1 โ ChatGPT conversation # elif dialed == 2: self._weather_service() # extend here! # elif dialed == 3: self._news_service() # extend here! self.pulse_count = 0 if not self.switch.is_pressed: self.audio_manager.start_continuous_tone() # Back to dial tone
# System prompt โ customize personality here messages = [ {"role": "system", "content": "You are a humorous conversation partner " "engaged in a natural phone call. Keep answers " "concise and to the point."} ] # Stream response sentence-by-sentence for low latency playback stream = client.chat.completions.create( model="gpt-4o-mini", # or "gpt-4o" for smarter responses messages=messages, stream=True ) # Convert each sentence chunk to speech immediately response = client.audio.speech.create( model="tts-1", voice="alloy", # nova, echo, fable, onyx, shimmer input=sentence_buffer, speed=1.0 )
# Run manually (suppresses harmless warnings) python3 callGPT.py 2>/dev/null # โโ Autostart on boot via systemd โโโโโโโโโโโโโโโโโโโโโโโโโ sudo nano /etc/systemd/system/callgpt.service
[Unit] Description=Rotary Phone GPT Service After=network.target [Service] ExecStart=/home/pi/callGPT/venv/bin/python3 /home/pi/callGPT/callGPT.py WorkingDirectory=/home/pi/callGPT Restart=always RestartSec=10 User=pi Environment="OPENAI_API_KEY=sk-your-key-here" [Install] WantedBy=multi-user.target
sudo systemctl enable callgpt.service sudo systemctl start callgpt.service sudo reboot
No internet needed. Dial a number, play an MP3 from a micro SD card. Inspired by the Wonderfoon project โ originally built for people with dementia so they could dial a number and hear familiar songs.
| Component | Notes | Est. Cost |
|---|---|---|
| Wemos D1 Mini (ESP8266) | Any ESP8266 board works; D1 Mini is compact | ~โฌ3โ5 |
| DFPlayer Mini MP3 module | Buy from DFRobot or reputable supplier โ clones can be unreliable | ~โฌ2โ4 |
| Micro SD card (2โ32GB) | FAT32 formatted. Store MP3 files in numbered folders. | ~โฌ3 |
| 1kฮฉ resistor | Between TX of Wemos and RX of DFPlayer to limit current | <โฌ0.10 |
| Small speaker (4โ8ฮฉ) | Or use the handset's existing earpiece speaker (โ50โ150ฮฉ โ needs amplifier) | ~โฌ2 |
| Jumper wires + breadboard | For prototyping | ~โฌ2 |
Wemos D1 Mini DFPlayer Mini โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 5V โโ VCC GND โโ GND D7 (TX/GPIO13) โโ RX (via 1kฮฉ resistor) D6 (RX/GPIO12) โโ TX SPK_1 / SPK_2 โ Speaker Wemos D1 Mini Rotary Dial โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ D2 (GPIO4) โโ Pulse wire GND โโ Ground wire D1 (GPIO5) โโ Hook switch (one leg) GND โโ Hook switch (other leg)
Format the SD card as FAT32. Create numbered folders and put your MP3 files inside:
SD Card/
โโโ 01/ โ Dial 1 โ plays from this folder
โ โโโ 001.mp3 (first song)
โ โโโ 002.mp3
โ โโโ 003.mp3
โโโ 02/ โ Dial 2 โ plays from this folder
โ โโโ 001.mp3
โ โโโ 002.mp3
โโโ 03/ โ Dial 3 ...
โ โโโ 001.mp3
โโโ 04/ โ System sounds (startup, etc.)
โโโ 001.mp3 ("Welcome! Dial a number.")
โโโ 002.mp3 ("Goodbye!")// Install: DFRobotDFPlayerMini library via Arduino Library Manager #include <SoftwareSerial.h> #include <DFRobotDFPlayerMini.h> const int DIAL_PIN = D2; // Rotary dial pulse input const int HOOK_PIN = D1; // Hook switch input const int DF_RX = D6; // DFPlayer TX โ Wemos RX const int DF_TX = D7; // DFPlayer RX โ Wemos TX (via 1kฮฉ) SoftwareSerial dfSerial(DF_RX, DF_TX); DFRobotDFPlayerMini dfPlayer; volatile int pulseCount = 0; unsigned long lastPulseTime = 0; bool counting = false; void IRAM_ATTR onPulse() { // Called on each dial pulse (FALLING edge) pulseCount++; lastPulseTime = millis(); counting = true; } void setup() { pinMode(DIAL_PIN, INPUT_PULLUP); pinMode(HOOK_PIN, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(DIAL_PIN), onPulse, FALLING); dfSerial.begin(9600); dfPlayer.begin(dfSerial); dfPlayer.volume(25); // 0โ30 dfPlayer.play(4, 1); // Play system/startup sound (folder 04, file 001) } void loop() { // 350ms after last pulse = digit complete if (counting && (millis() - lastPulseTime > 350)) { counting = false; int digit = (pulseCount == 10) ? 0 : pulseCount; // 10 pulses = 0 pulseCount = 0; if (digit >= 1 && digit <= 9) { dfPlayer.playFolder(digit, 1); // Play first file in folder {digit} } } }
The full Wonderfoon project (hvtil/Wonderfoon_wemos_lolin on GitHub) adds random track selection within a folder, volume control via dialing, and voice feedback messages. Check it out for a more polished version.
Both paths already detect dial pulses โ you just extend the if/elif chain. Here's the full robust pulse detection for Raspberry Pi using gpiozero:
from gpiozero import Button import time, threading DIAL_PIN = 23 DIGIT_TIMEOUT = 1.5 # seconds to wait after last pulse before committing digit pulse_count = 0 last_pulse_time = 0 dial_button = Button(DIAL_PIN, pull_up=True) def on_pulse(): global pulse_count, last_pulse_time pulse_count += 1 last_pulse_time = time.time() def read_digit(): """Blocks until a full digit has been dialed. Returns int 0โ9.""" global pulse_count, last_pulse_time pulse_count = 0 # Wait for first pulse to start while pulse_count == 0: time.sleep(0.05) # Collect pulses until DIGIT_TIMEOUT silence while time.time() - last_pulse_time < DIGIT_TIMEOUT: time.sleep(0.05) digit = pulse_count if pulse_count != 10 else 0 pulse_count = 0 return digit dial_button.when_pressed = on_pulse # Usage โ collect a multi-digit number (e.g. phone extension "42") dialed_digits = [] while True: d = read_digit() print(f"Digit dialed: {d}") dialed_digits.append(d) # e.g. stop collecting after 2 digits, or after dialing '*' (special pulse count)
You can collect multiple digits โ for example dial 42 to trigger a specific track. Just accumulate digits in a list and check after a longer pause, or after a fixed count. The Python read_digit() function above can be called in a loop.
Once you can read the dialed digit, the possibilities are endless. Here are ideas for every number on the dial:
Start a full conversational AI session. Speak freely, hang up to end.
Call OpenWeatherMap API, convert to speech with gTTS, play through handset.
Fetch top headlines from a news API, read aloud via TTS.
Stream a radio station or play a local playlist through the handset speaker.
Trigger lights, read sensor values, or send commands via Home Assistant REST API.
Fetch a random joke or motivational quote from a free API, read aloud.
Record a voice note to a file on the Pi. Dial again to play it back.
Start a kitchen timer. When done, ring the handset speaker with a tone.
Play a random retro/classic sound effect. Great for escape room props.
Read out all available dial options as a voice menu.
import requests from gtts import gTTS def _weather_service(self): """Dial 2 โ fetches weather for your city, plays it aloud.""" api_key = "your_openweather_key" city = "Turin" # change to your city url = f"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={api_key}&units=metric&lang=en" data = requests.get(url).json() desc = data['weather'][0]['description'] temp = round(data['main']['temp']) text = f"Current weather in {city}: {desc}, {temp} degrees Celsius." tts = gTTS(text, lang='en') tts.save('/tmp/weather.mp3') self.audio_manager.play_file('/tmp/weather.mp3') # In _check_number(), add: elif dialed == 2: self._weather_service()
Want to dial a full number like 42 for a specific track? Collect digits until a longer pause (e.g. 3 seconds) or a fixed count, then process the full number:
def read_multi_digit(self, num_digits=2): """Collect up to `num_digits` digits, return as int. E.g. dial 4โ2 = 42.""" digits = [] for _ in range(num_digits): d = self.read_digit() digits.append(str(d)) return int("".join(digits)) # "4","2" โ 42
DEBOUNCE_TIME if you're getting duplicate pulses (mechanical bounce)DIGIT_TIMEOUT if dialing is too slow to respondaplay test.wav first to confirm RPi audio output worksSILENCE_THRESHOLD โ lower in quiet rooms, higher in noisy oneslanguage="it" for Italian