Skip to content

Commit ee3acf6

Browse files
committed
Add tuner and recorder cli
1 parent 76a4ffa commit ee3acf6

5 files changed

Lines changed: 373 additions & 4 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const buffer = await ctx.startRendering()
5858
| [dtmf.js](examples/dtmf.js) | Dial a phone number — `5551234` |
5959
| [stereo-test.js](examples/stereo-test.js) | Left, right, center — `1k 1s` |
6060
| [metronome.js](examples/metronome.js) | Programmable click — `120..240 10m X-x-` |
61+
| [tuner.js](examples/tuner.js) | Guitar tuner — mic pitch in cents — `440` (requires [`audio-mic`](https://github.com/audiojs/audio-mic)) |
6162
| **Illusions** | |
6263
| [shepard.js](examples/shepard.js) | Pitch that rises forever — `up 15s` |
6364
| [risset-rhythm.js](examples/risset-rhythm.js) | Beat that accelerates forever — `up 120 20s` |
@@ -86,6 +87,7 @@ const buffer = await ctx.startRendering()
8687
| [process-file.js](examples/process-file.js) | Audio file → EQ + compress → render |
8788
| [pipe-stdout.js](examples/pipe-stdout.js) | PCM to stdout — pipe to `aplay`, `sox`, etc. |
8889
| [mic.js](examples/mic.js) | Live microphone → speakers with RMS meter (requires [`audio-mic`](https://github.com/audiojs/audio-mic)) |
90+
| [recorder.js](examples/recorder.js) | Record the mic to a WAV file, with a level meter (requires [`audio-mic`](https://github.com/audiojs/audio-mic)) |
8991

9092
## FAQ
9193

@@ -164,16 +166,21 @@ const stream = new MediaStream([track])
164166
const src = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream })
165167
src.connect(ctx.destination) // live monitor
166168

169+
// audio-mic's read(cb) is single-shot — re-arm from inside the callback to keep draining the device.
167170
const read = mic({ sampleRate: ctx.sampleRate, channels: 1, bitDepth: 16 })
168-
read((err, buf) => {
171+
const pump = () => read((err, buf) => {
169172
if (err || !buf) return
170173
track.pushData(buf, { channels: 1, bitDepth: 16 })
174+
pump()
171175
})
176+
pump()
172177
```
173178

174179
`track.pushData()` accepts `Float32Array`, `Float32Array[]`, or interleaved 8/16/32-bit integer PCM buffers. Integer PCM conversion uses `pcm-convert`. `CustomMediaStreamTrack` extends `MediaStreamTrack` — prior art: `CanvasCaptureMediaStreamTrack`.
175180

176181
See [examples/mic.js](examples/mic.js) for a runnable demo with gain and VU meter. To record the graph to a buffer, use `OfflineAudioContext.startRendering()`. To capture live graph output as a stream, use `ctx.createMediaStreamDestination()`.
182+
183+
If the mic opens but delivers silence on macOS (a known issue with audio-mic's prebuilt CoreAudio binding under some TCC configurations), pass `backend: 'process'` to use `sox`/`ffmpeg` instead: `mic({ ..., backend: 'process' })`. All bundled examples accept `backend=process` on the command line.
177184
</dd>
178185

179186
<dt>How do I use it as a polyfill?</dt>

examples/_util.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,28 @@ export let keys = (bindings = {}, onQuit, ctx) => {
5656
return quit
5757
}
5858

59+
// Truncate to a visible column count, stepping over ANSI escapes so colors aren't cut mid-sequence.
60+
let clip = (s, w) => {
61+
let out = '', vis = 0
62+
for (let i = 0; i < s.length;) {
63+
if (s[i] === '\x1b') {
64+
let j = s.indexOf('m', i)
65+
if (j < 0) break
66+
out += s.slice(i, j + 1); i = j + 1
67+
} else if (vis < w) { out += s[i++]; vis++ }
68+
else return out + '\x1b[0m'
69+
}
70+
return out
71+
}
72+
5973
// Live single-line status: returns a function that overwrites the same terminal line.
74+
// Clipped to the terminal width — a status longer than one line would wrap and pile up.
6075
export let status = () => {
6176
let last = ''
6277
return s => {
6378
if (s === last) return
6479
last = s
65-
if (process.stdout.isTTY) process.stdout.write('\r\x1b[K' + s)
80+
if (process.stdout.isTTY) process.stdout.write('\r\x1b[K' + clip(s, (process.stdout.columns || 80) - 1))
6681
else process.stdout.write(s + '\n')
6782
}
6883
}

examples/mic.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ let gainVal = parseFloat($('gain', '1'))
1414
let sampleRate = parseInt($('rate', '44100'))
1515
let channels = parseInt($('ch', '1'))
1616
let bitDepth = parseInt($('bit', '16'))
17+
let backend = $('backend') // 'miniaudio' (default) or 'process' (sox/ffmpeg fallback)
1718

1819
const ctx = new AudioContext({ sampleRate })
1920
await ctx.resume()
@@ -28,11 +29,14 @@ gain.gain.value = gainVal
2829
analyser.fftSize = 1024
2930
src.connect(gain).connect(analyser).connect(ctx.destination)
3031

31-
let read = mic({ sampleRate, channels, bitDepth })
32-
read((err, buf) => {
32+
// audio-mic's read(cb) is one-shot — re-arm from inside the callback to keep draining the device.
33+
let read = mic({ sampleRate, channels, bitDepth, ...(backend && { backend }) })
34+
let pump = () => read((err, buf) => {
3335
if (err || !buf) return
3436
track.pushData(buf, { channels, bitDepth })
37+
pump()
3538
})
39+
pump()
3640

3741
let samples = new Float32Array(analyser.fftSize)
3842
let print = status()

examples/recorder.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Recorder — capture the mic to a WAV file, with a live level meter.
2+
// mic → gain → recorder node → destination (silent) — every sample is captured through the graph.
3+
// Requires the `audio-mic` package (cross-platform Node mic capture):
4+
// npm i audio-mic
5+
// Run: node examples/recorder.js
6+
// Run: node examples/recorder.js take1 gain=2 # preset name + input gain
7+
// Run: node examples/recorder.js rate=48000 ch=2
8+
// Keys while recording: Enter save · +/- input gain · q cancel
9+
10+
import { writeFileSync } from 'node:fs'
11+
import readline from 'node:readline'
12+
import { AudioContext, MediaStream, CustomMediaStreamTrack, MediaStreamAudioSourceNode } from 'web-audio-api'
13+
import convert from 'pcm-convert'
14+
import { args, status, clearLine } from './_util.js'
15+
16+
let { pos, $ } = args()
17+
let sampleRate = parseInt($('rate', '44100'))
18+
let channels = parseInt($('ch', '1'))
19+
let bitDepth = parseInt($('bit', '16'))
20+
let backend = $('backend') // 'miniaudio' (default) or 'process' (sox/ffmpeg fallback)
21+
let nameArg = pos.find(t => !/^\d/.test(t)) // first non-numeric positional → default filename
22+
23+
// audio-mic is an optional peer dependency — fail with a hint, not a stack trace.
24+
let mic
25+
try { mic = (await import('audio-mic')).default }
26+
catch { console.error('Microphone capture needs the audio-mic package:\n npm i audio-mic'); process.exit(1) }
27+
28+
let C = process.stdout.isTTY
29+
let paint = (s, c) => C ? `\x1b[${c}m${s}\x1b[0m` : s
30+
let p2 = n => String(n).padStart(2, '0')
31+
let clock = s => `${Math.floor(s / 60)}:${p2(Math.floor(s % 60))}`
32+
let stamp = () => { let d = new Date(); return `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}` }
33+
34+
// --- audio graph: mic → gain → recorder; the recorder outputs silence ---
35+
let ctx = new AudioContext({ sampleRate })
36+
await ctx.resume()
37+
38+
let track = new CustomMediaStreamTrack({ kind: 'audio', label: 'mic', settings: { channelCount: channels, sampleSize: bitDepth, sampleRate } })
39+
let src = new MediaStreamAudioSourceNode(ctx, { mediaStream: new MediaStream([track]) })
40+
let gain = ctx.createGain()
41+
gain.gain.value = parseFloat($('gain', '1'))
42+
let recorder = ctx.createScriptProcessor(2048, channels, channels)
43+
44+
let chunks = [] // captured frames: [ [Float32Array per channel], ... ]
45+
let frames = 0 // total sample frames captured
46+
let level = 0, peak = 0 // live RMS and session peak
47+
let recording = true
48+
recorder.onaudioprocess = e => {
49+
if (!recording) return
50+
let ib = e.inputBuffer, frame = []
51+
for (let c = 0; c < channels; c++) frame.push(Float32Array.from(ib.getChannelData(c)))
52+
chunks.push(frame)
53+
frames += ib.length
54+
let d = frame[0], sum = 0, pk = 0
55+
for (let i = 0; i < d.length; i++) { sum += d[i] * d[i]; let a = Math.abs(d[i]); if (a > pk) pk = a }
56+
level = Math.sqrt(sum / d.length)
57+
if (pk > peak) peak = pk
58+
}
59+
src.connect(gain).connect(recorder).connect(ctx.destination)
60+
61+
let read
62+
try {
63+
// audio-mic's read(cb) is one-shot — re-arm from inside the callback to keep draining the device.
64+
read = mic({ sampleRate, channels, bitDepth, ...(backend && { backend }) })
65+
let pump = () => read((err, buf) => {
66+
if (err || !buf) return
67+
track.pushData(buf, { channels, bitDepth })
68+
pump()
69+
})
70+
pump()
71+
} catch (e) {
72+
console.error('Could not open the microphone:', e.message)
73+
ctx.close(); process.exit(1)
74+
}
75+
76+
// --- live meter ---
77+
let t0 = Date.now()
78+
let render = status()
79+
let ui = setInterval(() => {
80+
let secs = (Date.now() - t0) / 1000
81+
let db = 20 * Math.log10(Math.max(level, 1e-6))
82+
let bars = Math.max(0, Math.min(30, Math.round((db + 60) / 2)))
83+
let meter = '█'.repeat(bars) + '·'.repeat(30 - bars)
84+
let cc = peak >= 0.999 ? 31 : db > -6 ? 33 : 32
85+
let tag = peak >= 0.999 ? paint(' CLIP — lower gain', 31)
86+
: secs > 2 && peak < 5e-4 ? paint(' ⚠ no input — check mic permission', 31)
87+
: ''
88+
render(`${paint('●', 31)} REC ${clock(secs)} [${paint(meter, cc)}] ${paint(db.toFixed(1) + 'dB', cc)} gain ${gain.gain.value.toFixed(2)}${tag}`)
89+
}, 70)
90+
91+
// --- WAV writer (canonical 44-byte PCM header) ---
92+
function wavHeader(dataLen) {
93+
let h = Buffer.alloc(44)
94+
h.write('RIFF', 0); h.writeUInt32LE(36 + dataLen, 4); h.write('WAVE', 8)
95+
h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20)
96+
h.writeUInt16LE(channels, 22); h.writeUInt32LE(sampleRate, 24)
97+
h.writeUInt32LE(sampleRate * channels * bitDepth / 8, 28)
98+
h.writeUInt16LE(channels * bitDepth / 8, 32); h.writeUInt16LE(bitDepth, 34)
99+
h.write('data', 36); h.writeUInt32LE(dataLen, 40)
100+
return h
101+
}
102+
function save(file) {
103+
let merged = []
104+
for (let c = 0; c < channels; c++) {
105+
let arr = new Float32Array(frames), off = 0
106+
for (let f of chunks) { arr.set(f[c], off); off += f[c].length }
107+
merged.push(arr)
108+
}
109+
let pcm = Buffer.from(convert(merged, 'float32 planar', `int${bitDepth} interleaved le`).buffer)
110+
writeFileSync(file, Buffer.concat([wavHeader(pcm.length), pcm]))
111+
let kb = (44 + pcm.length) / 1024
112+
console.log(`saved ${file} · ${clock(frames / sampleRate)} · ${kb < 1024 ? kb.toFixed(0) + ' KB' : (kb / 1024).toFixed(1) + ' MB'} · ${sampleRate} Hz ${channels === 1 ? 'mono' : channels + 'ch'} ${bitDepth}-bit`)
113+
}
114+
115+
// --- input: keypresses while recording, then a line prompt for the filename ---
116+
let stdin = process.stdin
117+
readline.emitKeypressEvents(stdin)
118+
if (stdin.isTTY) stdin.setRawMode(true)
119+
stdin.resume()
120+
121+
let stopCapture = () => { recording = false; clearInterval(ui); try { read(null) } catch {} }
122+
let onKey = (str, key) => {
123+
if (!key) return
124+
if (key.name === 'return' || key.name === 'enter') return finish()
125+
if (key.name === 'q' || key.name === 'escape' || (key.ctrl && key.name === 'c')) return cancel()
126+
if (str === '+' || str === '=') gain.gain.value = Math.min(8, gain.gain.value * 1.26)
127+
if (str === '-' || str === '_') gain.gain.value = Math.max(0, gain.gain.value / 1.26)
128+
}
129+
stdin.on('keypress', onKey)
130+
131+
function done(msg) {
132+
if (stdin.isTTY) stdin.setRawMode(false)
133+
if (msg) console.log(msg)
134+
ctx.close()
135+
process.exit(0)
136+
}
137+
function cancel() {
138+
stopCapture(); stdin.off('keypress', onKey); clearLine()
139+
done('cancelled — nothing saved')
140+
}
141+
function finish() {
142+
stopCapture(); stdin.off('keypress', onKey); clearLine()
143+
if (!frames) return done('nothing recorded')
144+
if (stdin.isTTY) stdin.setRawMode(false)
145+
let def = (nameArg || `recording-${stamp()}`).replace(/\.wav$/i, '')
146+
let rl = readline.createInterface({ input: stdin, output: process.stdout })
147+
rl.question(`save as [${def}.wav]: `, answer => {
148+
rl.close()
149+
save((answer.trim() || def).replace(/\.wav$/i, '') + '.wav')
150+
done()
151+
})
152+
}
153+
154+
console.log(`Recorder · ${sampleRate} Hz · ${channels === 1 ? 'mono' : channels + ' channels'} · ${bitDepth}-bit (backend: ${read.backend || 'auto'})`)
155+
console.log('recording… press Enter to save · +/- input gain · q to cancel')

0 commit comments

Comments
 (0)