DIY Build Guide

Turn an Old Rotary Phone into
a Retro AI Companion

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.

๐Ÿค–

Path A โ€” RPi + ChatGPT

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.

Intermediate Needs internet ~โ‚ฌ30โ€“60
๐ŸŽต

Path B โ€” Wemos + DFPlayer

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.

Beginner Offline ~โ‚ฌ10โ€“15

How Rotary Dialing Works

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.

Pulse Counting Logic

  • Dial 1 โ†’ 1 pulse (1 brief circuit break)
  • Dial 2 โ†’ 2 pulses
  • Dial 5 โ†’ 5 pulses
  • Dial 0 โ†’ 10 pulses (special case!)

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.

What You're Connecting

The rotary dial has 2โ€“4 wires. Two of them carry the pulse signal. To find them:

  1. Set your multimeter to resistance/continuity mode
  2. Connect two wires, then slowly dial a number
  3. If the multimeter shows changing readings โ†’ those are the pulse wires
  4. One wire goes to GND, the other to a GPIO input pin
โš ๏ธ
0 = 10 pulses

Always handle this edge case in your code: if pulse_count == 10, the digit dialed is 0, not 10.

The Hook Switch

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            โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Raspberry Pi + ChatGPT Hotline

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.

โ„น๏ธ
Original project

Full source at polluxlabs.io by Frederik Kumbartzki. MIT Licensed.

๐Ÿ›’ Parts List

ComponentNotesEst. Cost
Raspberry Pi Zero 2 W or Pi 4Pi 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 buttonSmall enough to fit under the hook fork mechanism~โ‚ฌ1
Jumper cables (20cm)For connecting button and dial to GPIO header~โ‚ฌ2
OpenAI API keyNeeds Whisper (STT), GPT-4o-mini (LLM), TTS. ~โ‚ฌ0.01โ€“0.05 per conversationPay-as-go

๐Ÿ”Œ Wiring

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)

โš™๏ธ Software Setup

1

Flash Raspberry Pi OS Lite (64-bit)

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.

2

Connect via SSH and update

bash
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
3

Create project and virtual environment

bash
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
4

Generate audio files (dial tone + error sounds)

bash
# 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')"
5

Store your OpenAI API key

bash
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.

๐Ÿ The Python Script

Create callGPT.py with nano callGPT.py. Here's the architecture and key configuration sections:

Pin constants (top of file)

python
# โ”€โ”€ 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

Pulse reading โ€” RotaryDialer class

python
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

AI conversation loop (inside _call_gpt_service)

python
# 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
)

โ–ถ๏ธ Running and Autostart

bash
# Run manually (suppresses harmless warnings)
python3 callGPT.py 2>/dev/null

# โ”€โ”€ Autostart on boot via systemd โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
sudo nano /etc/systemd/system/callgpt.service
ini โ€” 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
bash
sudo systemctl enable callgpt.service
sudo systemctl start callgpt.service
sudo reboot

Wemos D1 Mini + DFPlayer (Offline)

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.

๐Ÿ›’ Parts List

ComponentNotesEst. Cost
Wemos D1 Mini (ESP8266)Any ESP8266 board works; D1 Mini is compact~โ‚ฌ3โ€“5
DFPlayer Mini MP3 moduleBuy 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ฮฉ resistorBetween 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 + breadboardFor prototyping~โ‚ฌ2

๐Ÿ”Œ Wiring

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)

๐Ÿ“‚ SD Card Structure

Format the SD card as FAT32. Create numbered folders and put your MP3 files inside:

file structure
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!")

โŒจ๏ธ Arduino Sketch (Key Logic)

C++ / Arduino
// 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}
    }
  }
}
โœ…
Wonderfoon enhancement

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.

Reading Every Dialed Number

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:

python โ€” robust pulse counter (RPi)
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)
๐Ÿ’ก
Multi-digit numbers

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.

What You Can Map to Each Number

Once you can read the dialed digit, the possibilities are endless. Here are ideas for every number on the dial:

1

ChatGPT Conversation

Start a full conversational AI session. Speak freely, hang up to end.

2

Weather Forecast

Call OpenWeatherMap API, convert to speech with gTTS, play through handset.

3

News Headlines

Fetch top headlines from a news API, read aloud via TTS.

4

Music / Radio

Stream a radio station or play a local playlist through the handset speaker.

5

Home Assistant

Trigger lights, read sensor values, or send commands via Home Assistant REST API.

6

Daily Joke / Quote

Fetch a random joke or motivational quote from a free API, read aloud.

7

Voice Memo Record

Record a voice note to a file on the Pi. Dial again to play it back.

8

Countdown Timer

Start a kitchen timer. When done, ring the handset speaker with a tone.

9

Retro Sound Effects

Play a random retro/classic sound effect. Great for escape room props.

0

Help / Menu

Read out all available dial options as a voice menu.

Example: Adding a Weather Service (RPi)

python
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()

Multi-Digit Input

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:

python
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

Common Issues & Fixes

Pulses not detected / wrong digit count

  • Check your multimeter test โ€” make sure you found the right two wires on the dial
  • Increase DEBOUNCE_TIME if you're getting duplicate pulses (mechanical bounce)
  • Reduce DIGIT_TIMEOUT if dialing is too slow to respond
  • Remember: dial 0 produces 10 pulses

No audio from handset speaker

  • Test with aplay test.wav first to confirm RPi audio output works
  • Make sure the 2.8mm flat connectors are firmly seated in the handset socket
  • Try swapping red/white cable (left vs right stereo channel)

Whisper not recognizing speech

  • Adjust SILENCE_THRESHOLD โ€” lower in quiet rooms, higher in noisy ones
  • Try a better USB microphone closer to your mouth
  • Check the language parameter: language="it" for Italian

DFPlayer not responding (Path B)

  • Confirm 1kฮฉ resistor on the TXโ†’RX line
  • Some clone DFPlayers need different sleep commands โ€” see Wonderfoon README fix
  • Make sure SD card is FAT32 and folder/file names are strictly zero-padded numbers