我當兵時陰錯陽差在通信部隊修了兩年交換機。

說「修」有點浮誇,其實從頭到尾就只會一招,交換機接話機的電路機板上有顆 SLIC (Subscriber Line Interface Circuit) IC,是最容易損壞的元件。遇到單位送修機板,我們的三板斧就只有換掉 SLIC 試試,若沒好一律後送原廠,哈!

不過,因為這段經歷,我對傳統電話的原理也算略懂。傳統類比電話有個技術叫 Dual-Tone Multi-Frequency 雙音多頻,簡稱 DTMF,它在智慧型手機上還沒絕跡,就是遇到電腦語音「請直接撥分機號碼,或撥 9 由總機為您服務」時用螢幕小鍵盤按鍵發出的不同頻率聲音。遠端的設備可精準地將這些頻率訊號還原對映 1, 2, 3, 4... 等數字或 #、* 字元。

我最近發現一個有趣的 Arduino 零件 - MT8870 模組,50 元有找,用耳機線接收撥號音,MT8870 晶片可將各頻率聲音轉成對應數字或字元的二進位值,省去自己寫濾波器演算法識別的工夫,效率與精準度也高 N 倍。

模組上 StD 輸出代表是否目前有按鍵音,D1 到 D4 則分別代表由低到高的 4 個位元,對映 0 ~ 9、#、* 與 A ~ D。音調持續與間隔的最短時間為 40ms ,每秒最可識別按鍵數量的極限值是 12.5 個。

DigitQ4Q3Q2Q1
10001
20010
30011
40100
50101
60110
70111
81000
91001
01010
*1011
#1100
A1101
B1110
C1111
D0000

利用先前提過的 Web Audio API,我們可以輕鬆寫出網頁版的電話撥號鍵盤:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>DTMF Dialer</title>
  <style>
    body {
      margin: 0; min-height: 100vh;
      display: grid; place-items: center;
      font-family: sans-serif;
      background: #e9eee9;
    }
    .dialer {
      padding: 16px 20px 20px;
      border: 1px solid #bcc5bc;
      border-top: 4px solid #3a6b58;
      border-radius: 8px;
      background: #f8f7f1;
      box-shadow: 0 4px 16px rgba(0,0,0,.12);
    }
    .dialer-header {
      display: flex; align-items: center; justify-content: space-between;
      padding-bottom: 10px; margin-bottom: 12px; border-bottom: 1px solid #d9ddd8;
    }
    .dialer-header h1 { margin: 0; font-size: 20px; }
    table { border-collapse: separate; border-spacing: 8px; }
    th, td { padding: 0; text-align: center; }
    .frequency { font: 600 12px/1 monospace; color: #52645e; }
    .dtmf-key {
      width: 52px; height: 52px; padding: 0;
      border: 1px solid #333; border-bottom-width: 3px; border-radius: 6px;
      background: #3d4643; color: #f0ede0;
      font: 700 22px/1 sans-serif; cursor: pointer;
      touch-action: none; user-select: none;
      transition: transform 80ms, box-shadow 80ms;
      box-shadow: 0 3px 0 #111;
    }
    .dtmf-key.fn { background: #7a3f38; border-color: #4e241f; box-shadow: 0 3px 0 #3a1a16; }
    .dtmf-key:hover { filter: brightness(1.15); }
    .dtmf-key:active, .dtmf-key.is-playing {
      border-bottom-width: 1px; transform: translateY(2px); box-shadow: none;
      background: #1e2523;
    }
    .dtmf-key.fn:active, .dtmf-key.fn.is-playing { background: #4e2420; }
    .dtmf-key:focus-visible { outline: 2px solid #1d6eb5; outline-offset: 2px; }
  </style>
</head>
<body>
  <div id="app">
    <div class="dialer" aria-label="DTMF dialer">
      <header class="dialer-header">
        <h1>DTMF Dialer</h1>
      </header>
      <table>
        <thead>
          <tr>
            <th></th>
            <th v-for="c in colFreqs" class="frequency">{{ c }}Hz</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(rowFreq, ri) in rowFreqs">
            <th class="frequency">{{ rowFreq }}Hz</th>
            <td v-for="(label, ci) in keys[ri]">
              <button type="button"
                class="dtmf-key" :class="{ 'is-playing': playingKey === `${ri}-${ci}`, fn: ci === 3 }"
                @pointerdown.prevent="startKey($event, `${ri}-${ci}`, rowFreq, colFreqs[ci])"
                @pointerup="stopTone" @pointercancel="stopTone" @lostpointercapture="stopTone"
                @keydown="onKeyDown($event, `${ri}-${ci}`, rowFreq, colFreqs[ci])" @keyup="onKeyUp">
                {{ label }}
              </button>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
  <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
  <script>
    const LOW_FREQS = [1209, 1336, 1477, 1633];
    const HIGH_FREQS = [697, 770, 852, 941];
    const KEYS = [ ['1', '2', '3', 'A'], ['4', '5', '6', 'B'], ['7', '8', '9', 'C'], ['*', '0', '#', 'D'] ];
    const AudioCtxClass = window.AudioContext || window.webkitAudioContext;
    let audioCtx, activeTone, toneReq = 0;

    const app = Vue.createApp({
      data() {
        return { colFreqs: LOW_FREQS, rowFreqs: HIGH_FREQS, keys: KEYS, playingKey: null };
      },
      methods: {
        stopTone() {
          toneReq++;
          if (!activeTone) return;
          const { oscillators, gain } = activeTone;
          const now = audioCtx.currentTime;
          gain.gain.cancelScheduledValues(now);
          gain.gain.setValueAtTime(gain.gain.value, now);
          gain.gain.linearRampToValueAtTime(0, now + 0.02);
          oscillators.forEach(o => o.stop(now + 0.02));
          this.playingKey = null;
          activeTone = null;
        },
        async playTone(keyId, low, high) {
          if (!AudioCtxClass) return;
          this.stopTone();
          const req = toneReq;
          audioCtx ||= new AudioCtxClass();
          await audioCtx.resume();
          if (req !== toneReq) return;
          const gain = audioCtx.createGain();
          const now = audioCtx.currentTime;
          gain.gain.setValueAtTime(0, now);
          gain.gain.linearRampToValueAtTime(0.12, now + 0.01);
          gain.connect(audioCtx.destination);
          const oscillators = [low, high].map(freq => {
            const osc = audioCtx.createOscillator();
            osc.frequency.value = freq;
            osc.connect(gain);
            osc.start();
            return osc;
          });
          this.playingKey = keyId;
          activeTone = { oscillators, gain };
        },
        startKey(e, keyId, low, high) {
          e.target.setPointerCapture(e.pointerId);
          this.playTone(keyId, low, high);
        },
        onKeyDown(e, keyId, low, high) {
          if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) this.playTone(keyId, low, high);
        },
        onKeyUp(e) {
          if (e.key === ' ' || e.key === 'Enter') this.stopTone();
        }
      },
      mounted()   { window.addEventListener('blur', this.stopTone); },
      unmounted() { window.removeEventListener('blur', this.stopTone); }
    });
    app.mount('#app');
  </script>
</body>
</html>

線上展示

接著,拿出 ESP 開發板寫幾行程式,我們就能實現用耳機線來傳資料囉~ (灑花)

展示影片

Explores DTMF dialing tones, the MT8870 decoder module, and a Web Audio API phone keypad demo, then shows how an ESP board can receive tone-encoded data through an audio cable.


Comments

Be the first to post a comment

Post a comment