DTMF Handling

Handle touch-tone input (DTMF) for menu navigation, PIN entry, and phone number collection.

DTMF Handling

DTMF (Dual-Tone Multi-Frequency) allows users to interact using their phone keypad, essential for secure input and menu navigation.

What is DTMF?

Phone Keypad:
┌─────┬─────┬─────┐
│  1  │  2  │  3  │
│     │ ABC │ DEF │
├─────┼─────┼─────┤
│  4  │  5  │  6  │
│ GHI │ JKL │ MNO │
├─────┼─────┼─────┤
│  7  │  8  │  9  │
│PQRS │ TUV │WXYZ │
├─────┼─────┼─────┤
│  *  │  0  │  #  │
│     │     │     │
└─────┴─────┴─────┘

Each key generates a unique tone pair
that can be detected and processed.

Configuration

Enable DTMF

{
  "agent": {
    "name": "IVR Agent",
    "dtmf": {
      "enabled": true,
      "interDigitTimeout": 3000,
      "maxDigits": 20,
      "terminators": ["#"]
    }
  }
}

Configuration Options

Option Type Default Description
enabled bool true Enable DTMF detection
interDigitTimeout int 3000 Max ms between digits
maxDigits int 20 Maximum digits to collect
terminators array ["#"] Keys that end input
minDigits int 1 Minimum digits required

Implementation

DTMF Handler

type DTMFHandler struct {
    buffer     strings.Builder
    lastDigit  time.Time
    timeout    time.Duration
    maxDigits  int
    minDigits  int
    terminators []string
    callback   func(digits string)
}

func (h *DTMFHandler) OnDigit(digit string) {
    h.lastDigit = time.Now()
    h.buffer.WriteString(digit)

    // Check for terminator
    for _, term := range h.terminators {
        if digit == term {
            h.complete()
            return
        }
    }

    // Check max digits
    if h.buffer.Len() >= h.maxDigits {
        h.complete()
        return
    }

    // Start/reset timeout timer
    h.startTimeout()
}

func (h *DTMFHandler) startTimeout() {
    go func() {
        time.Sleep(h.timeout)
        if time.Since(h.lastDigit) >= h.timeout {
            if h.buffer.Len() >= h.minDigits {
                h.complete()
            }
        }
    }()
}

func (h *DTMFHandler) complete() {
    digits := h.buffer.String()
    // Remove terminator if present
    digits = strings.TrimSuffix(digits, "#")
    digits = strings.TrimSuffix(digits, "*")

    h.buffer.Reset()
    h.callback(digits)
}

Integration with Pipeline

func (p *Pipeline) handleDTMF(digit string) {
    // Pause STT during DTMF collection
    if p.collectingDTMF {
        p.dtmfHandler.OnDigit(digit)
        return
    }

    // Single digit actions
    switch digit {
    case "0":
        p.transferToOperator()
    case "*":
        p.repeatLastMessage()
    default:
        // Treat as start of DTMF sequence
        p.startDTMFCollection(digit)
    }
}

Common Use Cases

systemPrompt := `You are an IVR assistant.

When presenting options, always say:
"Press 1 for [option], Press 2 for [option]..."

Available menus:
- Main: 1=Orders, 2=Support, 3=Billing, 0=Operator
- Orders: 1=Status, 2=Returns, 3=New Order
- Support: 1=Technical, 2=Account, 3=Other`

func (p *Pipeline) handleMenuDTMF(digit string) {
    menu := p.currentMenu

    switch menu {
    case "main":
        switch digit {
        case "1":
            p.setMenu("orders")
            p.tts.Speak("Orders menu. Press 1 for order status...")
        case "2":
            p.setMenu("support")
            p.tts.Speak("Support menu. Press 1 for technical support...")
        case "0":
            p.transferToOperator()
        }
    case "orders":
        // Handle orders submenu
    }
}

PIN/Password Entry

func (p *Pipeline) collectPIN() string {
    p.tts.Speak("Please enter your 4-digit PIN, followed by the pound key.")

    // Pause recording for security
    p.recorder.Pause()
    defer p.recorder.Resume()

    // Mute STT to avoid PIN in transcript
    p.stt.Mute()
    defer p.stt.Unmute()

    // Collect DTMF
    p.dtmfHandler.Configure(DTMFConfig{
        MinDigits:   4,
        MaxDigits:   4,
        Terminators: []string{"#"},
        Timeout:     10 * time.Second,
    })

    pin := <-p.dtmfHandler.Result()

    return pin
}

Phone Number Collection

func (p *Pipeline) collectPhoneNumber() string {
    p.tts.Speak("Please enter your 10-digit phone number, followed by the pound key.")

    p.dtmfHandler.Configure(DTMFConfig{
        MinDigits:   10,
        MaxDigits:   11, // Allow country code
        Terminators: []string{"#"},
        Timeout:     15 * time.Second,
    })

    phone := <-p.dtmfHandler.Result()

    // Validate
    if len(phone) < 10 {
        p.tts.Speak("I didn't get a complete phone number. Let's try again.")
        return p.collectPhoneNumber()
    }

    // Format and confirm
    formatted := formatPhoneNumber(phone)
    p.tts.Speak(fmt.Sprintf("I have %s. Is that correct? Press 1 for yes, 2 for no.",
        speakPhoneNumber(formatted)))

    confirmation := <-p.dtmfHandler.Result()
    if confirmation == "1" {
        return formatted
    }

    return p.collectPhoneNumber()
}

func speakPhoneNumber(phone string) string {
    // "4 1 5. 5 5 5. 1 2 3 4"
    var parts []string
    for i, digit := range phone {
        if i == 3 || i == 6 {
            parts = append(parts, ".")
        }
        parts = append(parts, string(digit))
    }
    return strings.Join(parts, " ")
}

Credit Card Entry (PCI Compliant)

func (p *Pipeline) collectCardNumber() string {
    p.tts.Speak("For security, please enter your 16-digit card number using your keypad, followed by pound.")

    // Disable recording and transcription
    p.recorder.Pause()
    p.stt.Mute()

    p.dtmfHandler.Configure(DTMFConfig{
        MinDigits:   15, // Some cards have 15 digits
        MaxDigits:   16,
        Terminators: []string{"#"},
        Timeout:     30 * time.Second,
        MaskInput:   true, // Don't log digits
    })

    cardNumber := <-p.dtmfHandler.Result()

    // Re-enable
    p.recorder.Resume()
    p.stt.Unmute()

    // Validate using Luhn algorithm
    if !isValidCardNumber(cardNumber) {
        p.tts.Speak("That doesn't appear to be a valid card number. Let's try again.")
        return p.collectCardNumber()
    }

    // Confirm last 4 digits only
    last4 := cardNumber[len(cardNumber)-4:]
    p.tts.Speak(fmt.Sprintf("Card ending in %s. Is that correct? Press 1 for yes, 2 for no.",
        speakDigits(last4)))

    if <-p.dtmfHandler.Result() == "1" {
        return cardNumber
    }

    return p.collectCardNumber()
}

func isValidCardNumber(number string) bool {
    // Luhn algorithm
    sum := 0
    alternate := false

    for i := len(number) - 1; i >= 0; i-- {
        n := int(number[i] - '0')
        if alternate {
            n *= 2
            if n > 9 {
                n -= 9
            }
        }
        sum += n
        alternate = !alternate
    }

    return sum%10 == 0
}

Provider Integration

Twilio

func (h *TwilioHandler) handleMediaMessage(msg TwilioMediaMessage) {
    if msg.Event == "dtmf" {
        digit := msg.Dtmf.Digit
        h.pipeline.HandleDTMF(digit)
    }
}

Plivo

func (h *PlivoHandler) handleControlMessage(msg PlivoControlMessage) {
    if msg.Event == "dtmf" {
        digit := msg.Digit
        h.pipeline.HandleDTMF(digit)
    }
}

Exotel

func (h *ExotelHandler) handleDTMF(digit string) {
    h.pipeline.HandleDTMF(digit)
}

LLM Integration

Let the LLM handle DTMF naturally:

systemPrompt := `You are a voice assistant with DTMF capability.

When you need numeric input (phone numbers, PINs, account numbers),
ask the user to enter it on their keypad.

Example:
User: "I want to check my account balance"
You: "I can help with that. Please enter your 6-digit account number on your keypad, followed by the pound key."

After receiving DTMF input, you'll get a message like:
[DTMF Input: 123456]

Then continue the conversation naturally.`

func (p *Pipeline) handleDTMFComplete(digits string) {
    // Inject as system message
    p.context.AddMessage(Message{
        Role:    "system",
        Content: fmt.Sprintf("[DTMF Input: %s]", digits),
    })

    // Continue LLM conversation
    p.processWithLLM()
}

Error Handling

func (h *DTMFHandler) handleTimeout() {
    if h.buffer.Len() == 0 {
        h.callback("timeout:no_input")
    } else if h.buffer.Len() < h.minDigits {
        h.callback("timeout:incomplete:" + h.buffer.String())
    } else {
        h.complete()
    }
}

func (p *Pipeline) handleDTMFResult(result string) {
    if strings.HasPrefix(result, "timeout:") {
        if result == "timeout:no_input" {
            p.tts.Speak("I didn't receive any input. Let me repeat that.")
            p.repeatLastPrompt()
        } else {
            p.tts.Speak("I didn't get enough digits. Please try again.")
            p.retryDTMFCollection()
        }
        return
    }

    // Valid input
    p.processDTMFInput(result)
}

Best Practices

1. Clear Instructions

// Good
"Please enter your 10-digit phone number, followed by the pound sign."

// Bad
"Enter your phone number."

2. Confirmation for Important Data

func confirmInput(input, inputType string) bool {
    message := fmt.Sprintf("You entered %s. Press 1 to confirm, or 2 to re-enter.",
        formatForSpeech(input, inputType))
    tts.Speak(message)

    confirmation := waitForDTMF(1)
    return confirmation == "1"
}

3. Timeout Handling

func (p *Pipeline) collectWithRetry(prompt string, config DTMFConfig) (string, error) {
    maxRetries := 3

    for attempt := 0; attempt < maxRetries; attempt++ {
        p.tts.Speak(prompt)
        result := p.collectDTMF(config)

        if !strings.HasPrefix(result, "timeout:") {
            return result, nil
        }

        if attempt < maxRetries-1 {
            p.tts.Speak("I didn't catch that. Let me try again.")
        }
    }

    return "", fmt.Errorf("DTMF collection failed after %d attempts", maxRetries)
}

Next Steps