proof/docs/dial
dial

dial

Plays the two tones a phone sends for each key, decodes them back out of the audio, and draws them on a live spectrum. Built on the Web Audio API.

Try it

Press a key, or hold one down.

Install

Download the file into your project and import it. There is no package and no build step, so the code is yours to change.

curl -O /proof/r/dial.js
curl -O /proof/r/dial-scope.js

Usage

example.js
import { keypad } from './dial.js'
import { line } from './dial-scope.js'

const kp = keypad()
line(canvas, kp)

button.addEventListener('pointerdown', () => kp.press('5'))
button.addEventListener('pointerup', () => kp.release())

API

keypad(audioContext?)

optiontypedefaultwhat it does
audioContextAudioContextnew AudioContext()Pass your own to share one audio graph with the rest of the page.

Returns

press(key)Starts the two tones for 0-9, *, # or A-D.
release()Stops them with a short fade, so there is no click.
dial(keys, gap?, hold?)Plays a string of keys such as '5551234'. Resolves when the last key ends.
decode()Returns the key it hears in the live signal, or null.
signal(name)Plays a network tone such as 'busy'. Returns its length in ms.
analyserThe AnalyserNode the scopes draw from.
ctxThe AudioContext the keypad plays through.

Also exported

tones(key)Returns the two frequencies of a key, such as [770, 1336] for 5.
LOW, HIGH, KEYSThe row tones, the column tones and the key layout.
SIGNALSNetwork tones that signal() can play, such as 'busy' and 'ringback, US'.
line(canvas, kp, options?)From dial-scope.js. Draws the live spectrum with the eight key tones marked. Options: maxHz, guides, active (a function returning the tones to highlight), color, guide, hot, fill and font. Returns { destroy }.
waterfall(canvas, kp, options?)From dial-scope.js. Draws a scrolling spectrogram. Options: maxHz, bg, hot, live (height of a live line under it) and stroke. Returns { destroy }.

Notes

  • Call press() from a click or key handler. Browsers keep audio silent until the reader does something, and press() starts it at that moment.
  • decode() ignores anything quieter than -45 dB, so network tones never read as keys.

Source

The whole library. Read it, copy it, change it.

dial.js (1.5 KB gzipped)download
// dial.js from proof, by Alok Ranjan (https://alokranjan.me). MIT license.
export const LOW = [697, 770, 852, 941]
export const HIGH = [1209, 1336, 1477, 1633]
export const KEYS = ['123A', '456B', '789C', '*0#D']

export const tones = key => {
  const r = KEYS.findIndex(row => row.includes(key))
  return r < 0 ? null : [LOW[r], HIGH[KEYS[r].indexOf(key)]]
}

// call progress tones: each step is [frequencies, on ms, off ms]; am is a tremolo rate in Hz
export const SIGNALS = {
  'dial tone, US': { steps: [[[350, 440], 1800, 0]] },
  'dial tone, India': { steps: [[[400], 1800, 0]], am: 25 },
  'ringback, US': { steps: [[[440, 480], 1600, 0]] },
  'busy': { steps: [[[480, 620], 500, 500], [[480, 620], 500, 500], [[480, 620], 500, 0]] },
  'number disconnected': { steps: [[[913.8], 274, 0], [[1370.6], 274, 0], [[1776.7], 380, 0]] },
  '2600 Hz seize': { steps: [[[2600], 900, 0]] },
}

export function keypad(ctx = new AudioContext()) {
  const out = ctx.createGain()
  out.gain.value = 0.18
  const analyser = ctx.createAnalyser()
  analyser.fftSize = 8192
  analyser.smoothingTimeConstant = 0.2
  analyser.minDecibels = -82
  analyser.maxDecibels = -18
  out.connect(analyser)
  analyser.connect(ctx.destination)
  let live = null

  function press(key) {
    const f = tones(key)
    if (!f) return
    release()
    ctx.resume()
    const env = ctx.createGain()
    env.gain.setValueAtTime(0, ctx.currentTime)
    env.gain.linearRampToValueAtTime(1, ctx.currentTime + 0.01)
    env.connect(out)
    const osc = f.map(hz => { const o = ctx.createOscillator(); o.frequency.value = hz; o.connect(env); o.start(); return o })
    live = { osc, env }
  }

  function release() {
    if (!live) return
    const { osc, env } = live
    env.gain.setTargetAtTime(0, ctx.currentTime, 0.015)
    osc.forEach(o => o.stop(ctx.currentTime + 0.1))
    live = null
  }

  // reads the two strongest DTMF bins back out of the live signal, so the decoder hears what the speaker plays
  function decode() {
    const bins = new Float32Array(analyser.frequencyBinCount)
    analyser.getFloatFrequencyData(bins)
    const level = hz => bins[Math.round(hz / (ctx.sampleRate / analyser.fftSize))]
    const best = list => list.reduce((a, hz) => (level(hz) > level(a) ? hz : a))
    const lo = best(LOW), hi = best(HIGH)
    if (level(lo) < -45 || level(hi) < -45) return null
    return KEYS[LOW.indexOf(lo)][HIGH.indexOf(hi)]
  }

  async function dial(number, gap = 90, hold = 140) {
    for (const k of number) {
      press(k)
      await new Promise(r => setTimeout(r, hold))
      release()
      await new Promise(r => setTimeout(r, gap))
    }
  }

  // schedules the whole signal on the audio clock, so on and off times hold even if the page is busy
  function signal(name) {
    const s = SIGNALS[name]
    if (!s) return 0
    release()
    ctx.resume()
    let t = ctx.currentTime + 0.02
    for (const [freqs, on, off] of s.steps) {
      const env = ctx.createGain()
      env.gain.setValueAtTime(0, t)
      env.gain.linearRampToValueAtTime(1, t + 0.008)
      env.gain.setValueAtTime(1, t + on / 1000 - 0.008)
      env.gain.linearRampToValueAtTime(0, t + on / 1000)
      let sink = env
      if (s.am) {
        const trem = ctx.createGain()
        trem.gain.value = 0.5
        const lfo = ctx.createOscillator()
        lfo.frequency.value = s.am
        const depth = ctx.createGain()
        depth.gain.value = 0.5
        lfo.connect(depth).connect(trem.gain)
        lfo.start(t)
        lfo.stop(t + on / 1000)
        trem.connect(env)
        sink = trem
      }
      env.connect(out)
      for (const hz of freqs) {
        const o = ctx.createOscillator()
        o.frequency.value = hz
        o.connect(sink)
        o.start(t)
        o.stop(t + on / 1000 + 0.02)
      }
      t += (on + off) / 1000
    }
    return (t - ctx.currentTime) * 1000
  }

  return { press, release, decode, dial, signal, analyser, ctx }
}
dial-scope.js (1.7 KB gzipped)download
// dial-scope.js from proof, by Alok Ranjan (https://alokranjan.me). MIT license.
// draws only while the canvas is on screen, so three scopes on one page cost one at a time
function loop(canvas, paint) {
  let raf = 0, seen = false
  const tick = () => { paint(); raf = seen ? requestAnimationFrame(tick) : 0 }
  const io = new IntersectionObserver(([e]) => { seen = e.isIntersecting; if (seen && !raf) raf = requestAnimationFrame(tick) })
  io.observe(canvas)
  return { destroy() { io.disconnect(); cancelAnimationFrame(raf); raf = 0; seen = false } }
}

function fit(canvas) {
  const dpr = devicePixelRatio || 1
  const W = Math.round(canvas.clientWidth * dpr), H = Math.round(canvas.clientHeight * dpr)
  const resized = canvas.width !== W || canvas.height !== H
  if (resized) { canvas.width = W; canvas.height = H }
  return { W, H, dpr, resized }
}

/**
 * @param {HTMLCanvasElement} canvas
 * @param {{ analyser: AnalyserNode, ctx: BaseAudioContext }} kp
 * @param {{ maxHz?: number, color?: string, guide?: string, hot?: string, font?: string, guides?: number[], active?: () => number[], fill?: string }} [options] */
export function line(canvas, kp, { maxHz = 1800, color = '#18181b', guide = '#e4e4e0', hot = '#2b59ff', font = 'Martian Mono', guides = [697, 770, 852, 941, 1209, 1336, 1477, 1633], active = () => [], fill = 'rgba(43,89,255,.08)' } = {}) {
  const ctx = canvas.getContext('2d')
  const bins = new Uint8Array(kp.analyser.frequencyBinCount)
  const hzPerBin = kp.ctx.sampleRate / kp.analyser.fftSize
  return loop(canvas, () => {
    const { W, H, dpr } = fit(canvas)
    const pad = 28 * dpr, top = 34 * dpr
    kp.analyser.getByteFrequencyData(bins)
    ctx.clearRect(0, 0, W, H)
    const on = active()
    ctx.font = `${11 * dpr}px ${font}`
    ctx.textAlign = 'center'
    // on narrow canvases the closest guides (697, 770) collide, so only the lit ones keep a label
    const roomy = Math.min(...guides.slice(1).map((g, i) => g - guides[i]).filter(d => d > 0)) / maxHz * W > ctx.measureText('0000').width + 6 * dpr
    for (const hz of guides) {
      const x = hz / maxHz * W, lit = on.includes(hz)
      ctx.fillStyle = lit ? hot : guide
      ctx.fillRect(x - (lit ? dpr : dpr / 2), top - 6 * dpr, lit ? 2 * dpr : dpr, H - pad - top + 6 * dpr)
      ctx.fillStyle = lit ? hot : '#8a8a85'
      if (roomy || lit) ctx.fillText(hz, x, H - 9 * dpr)
    }
    const n = Math.floor(maxHz / hzPerBin), step = W / n, base = H - pad
    ctx.beginPath()
    ctx.moveTo(0, base)
    for (let i = 0; i < n; i++) ctx.lineTo(i * step, base - (base - top) * bins[i] / 255)
    ctx.lineTo(W, base)
    ctx.fillStyle = fill
    ctx.fill()
    ctx.strokeStyle = color
    ctx.lineWidth = 2 * dpr
    ctx.lineJoin = 'round'
    ctx.stroke()
    for (const hz of on) {
      const x = hz / maxHz * W
      ctx.fillStyle = hot
      ctx.font = `600 ${13 * dpr}px ${font}`
      ctx.fillText(`${hz} Hz`, x, top - 14 * dpr)
    }
  })
}

export function waterfall(canvas, kp, { maxHz = 1800, bg = [12, 11, 14], hot = [255, 176, 32], live = 0, stroke = '#ffb020' } = {}) {
  const ctx = canvas.getContext('2d')
  const bins = new Uint8Array(kp.analyser.frequencyBinCount)
  const hzPerBin = kp.ctx.sampleRate / kp.analyser.fftSize
  const ramp = v => {
    const t = v / 255, a = Math.min(1, t * 1.7), b = Math.pow(t, 1.6)
    return `rgb(${Math.round(bg[0] + (hot[0] - bg[0]) * a)},${Math.round(bg[1] + (hot[1] - bg[1]) * b)},${Math.round(bg[2] + (hot[2] - bg[2]) * b)})`
  }
  return loop(canvas, () => {
    const { W, H, dpr, resized } = fit(canvas)
    const L = live * dpr, F = H - L
    if (resized) { ctx.fillStyle = `rgb(${bg})`; ctx.fillRect(0, 0, W, H) }
    kp.analyser.getByteFrequencyData(bins)
    ctx.drawImage(canvas, 0, dpr, W, F - dpr, 0, 0, W, F - dpr)
    const n = Math.floor(maxHz / hzPerBin), step = W / n
    for (let i = 0; i < n; i++) { ctx.fillStyle = ramp(bins[i]); ctx.fillRect(i * step, F - dpr, step + 1, dpr) }
    if (!L) return
    ctx.fillStyle = `rgb(${bg})`
    ctx.fillRect(0, F, W, L)
    ctx.beginPath()
    for (let i = 0; i < n; i++) { const y = H - 4 * dpr - (L - 8 * dpr) * bins[i] / 255; i ? ctx.lineTo(i * step, y) : ctx.moveTo(i * step, y) }
    ctx.strokeStyle = stroke
    ctx.lineWidth = 1.5 * dpr
    ctx.stroke()
  })
}