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.
- 3.2 KB gzipped
- 0 dependencies
- 2 files
- inspired by webaudio-controls
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.jscurl -O /proof/r/dial-scope.jsUsage
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?)
| option | type | default | what it does |
|---|---|---|---|
| audioContext | AudioContext | new 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. |
| analyser | The AnalyserNode the scopes draw from. |
| ctx | The AudioContext the keypad plays through. |
Also exported
| tones(key) | Returns the two frequencies of a key, such as [770, 1336] for 5. |
| LOW, HIGH, KEYS | The row tones, the column tones and the key layout. |
| SIGNALS | Network 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 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 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()
})
}