|
| 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