/ proof-of-concept / wavrecorder
Sign in
proof-of-concept / wavrecorder
Phone WAV recorder: raw capture via Web Audio, 16-bit WAV download, pages deploy
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 322161919f0f Browse files
4 changed files+285−0
.github/workflows/deploy.ymladded+29−0View file
@@ -0,0 +1,29 @@
1+name: deploy
2+on:
3+ push:
4+ branches: [main]
5+ workflow_dispatch:
6+
7+permissions:
8+ contents: read
9+ pages: write
10+ id-token: write
11+
12+concurrency:
13+ group: pages
14+ cancel-in-progress: false
15+
16+jobs:
17+ deploy:
18+ runs-on: ubuntu-latest
19+ environment:
20+ name: github-pages
21+ url: ${{ steps.deployment.outputs.page_url }}
22+ steps:
23+ - uses: actions/checkout@v4
24+ - uses: actions/configure-pages@v5
25+ - uses: actions/upload-pages-artifact@v3
26+ with:
27+ path: .
28+ - id: deployment
29+ uses: actions/deploy-pages@v4
README.mdadded+7−0View file
@@ -0,0 +1,7 @@
1+# wavrecorder
2+
3+A one-page recorder for a phone or laptop browser: press Record, press Stop, download the take as an uncompressed 16-bit WAV. It captures the raw microphone samples through the Web Audio API (not MediaRecorder, which would compress) and asks the browser to turn off echo cancellation, noise suppression and automatic gain control, so that decay times and levels in the recording are as faithful as the device allows. The settings the browser actually applied are shown on the page.
4+
5+Deployed by the `deploy` workflow on every push to `main`: https://wavrecorder--proof-of-concept.vault1-sites.magland.org/
6+
7+No build step; the site is the repository itself.
index.htmladded+240−0View file
@@ -0,0 +1,240 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6+<meta name="color-scheme" content="dark">
7+<title>WAV recorder</title>
8+<link rel="icon" href="data:,">
9+<style>
10+ :root { --bg: #15171a; --panel: #1f2328; --fg: #e6e8eb; --dim: #9aa0a8; --accent: #f0b429; --red: #e0483e; --green: #4fbf72; }
11+ * { box-sizing: border-box; }
12+ body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.4 system-ui, -apple-system, sans-serif; padding: 16px; padding-bottom: 40px; }
13+ h1 { font-size: 20px; margin: 4px 0 12px; }
14+ .sub { color: var(--dim); font-size: 14px; margin: 0 0 12px; }
15+ .panel { background: var(--panel); border-radius: 12px; padding: 14px; margin-bottom: 14px; }
16+ .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
17+ button { font: inherit; font-size: 18px; padding: 14px 20px; border-radius: 12px; border: 0; color: #111; background: var(--accent); cursor: pointer; min-width: 120px; }
18+ button:disabled { opacity: 0.4; cursor: default; }
19+ button.rec { background: var(--red); color: #fff; }
20+ button.small { font-size: 14px; padding: 8px 12px; min-width: 0; background: #2e3540; color: var(--fg); }
21+ #time { font-variant-numeric: tabular-nums; font-size: 28px; min-width: 90px; }
22+ .meter { position: relative; height: 18px; background: #0d0f12; border-radius: 9px; overflow: hidden; flex: 1 1 160px; }
23+ .meter > div { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: linear-gradient(90deg, var(--green) 0 70%, var(--accent) 70% 90%, var(--red) 90%); }
24+ #peak { font-variant-numeric: tabular-nums; min-width: 110px; text-align: right; color: var(--dim); font-size: 14px; }
25+ #peak.clip { color: var(--red); font-weight: 600; }
26+ .info { color: var(--dim); font-size: 13px; white-space: pre-wrap; }
27+ .take { display: grid; grid-template-columns: 1fr auto; gap: 8px 12px; align-items: center; padding: 10px 0; border-top: 1px solid #2e3540; }
28+ .take:first-child { border-top: 0; }
29+ .take .name { font-weight: 600; }
30+ .take .meta { color: var(--dim); font-size: 13px; grid-column: 1 / -1; }
31+ .take .actions { display: flex; gap: 8px; grid-column: 1 / -1; flex-wrap: wrap; }
32+ a.dl { display: inline-block; font-size: 14px; padding: 8px 12px; border-radius: 10px; background: var(--accent); color: #111; text-decoration: none; }
33+ audio { width: 100%; margin-top: 6px; }
34+ #err { color: var(--red); white-space: pre-wrap; }
35+ label.opt { display: inline-flex; gap: 6px; align-items: center; color: var(--dim); font-size: 14px; }
36+</style>
37+</head>
38+<body>
39+<h1>WAV recorder</h1>
40+<p class="sub">Records the raw microphone signal (no compression; echo cancellation, noise suppression and automatic gain turned off where the browser allows) and saves 16-bit WAV files.</p>
41+
42+<div class="panel">
43+ <div class="row">
44+ <button id="btnRec">Record</button>
45+ <button id="btnStop" disabled>Stop</button>
46+ <span id="time">0.0 s</span>
47+ </div>
48+ <div class="row" style="margin-top: 12px">
49+ <div class="meter"><div id="bar"></div></div>
50+ <span id="peak">peak — dBFS</span>
51+ </div>
52+ <div class="row" style="margin-top: 10px">
53+ <label class="opt"><input type="checkbox" id="optStereo"> stereo (if the device offers two channels)</label>
54+ </div>
55+ <p id="err"></p>
56+ <p class="info" id="info">Press Record; the browser will ask for microphone permission. Leave a few seconds of silence at the start of each take.</p>
57+</div>
58+
59+<div class="panel">
60+ <div class="row" style="justify-content: space-between">
61+ <strong>Takes</strong>
62+ <button class="small" id="btnClear">Clear list</button>
63+ </div>
64+ <div id="takes"></div>
65+</div>
66+
67+<script>
68+(() => {
69+ const $ = (id) => document.getElementById(id);
70+ const btnRec = $("btnRec"), btnStop = $("btnStop"), timeEl = $("time"), bar = $("bar"), peakEl = $("peak"), info = $("info"), err = $("err"), takesEl = $("takes");
71+ let ctx = null, stream = null, source = null, node = null;
72+ let chunks = [], nSamples = 0, sampleRate = 48000, channels = 1, recording = false, t0 = 0, peak = 0, timer = 0;
73+ let takeCount = 0;
74+
75+ function dbfs(x) { return x > 0 ? 20 * Math.log10(x) : -Infinity; }
76+ function fmtDb(x) { return isFinite(x) ? x.toFixed(1) : "-inf"; }
77+
78+ async function start() {
79+ err.textContent = "";
80+ const wantStereo = $("optStereo").checked;
81+ const constraints = {
82+ audio: {
83+ echoCancellation: false,
84+ noiseSuppression: false,
85+ autoGainControl: false,
86+ channelCount: wantStereo ? 2 : 1,
87+ sampleRate: 48000,
88+ },
89+ video: false,
90+ };
91+ try {
92+ stream = await navigator.mediaDevices.getUserMedia(constraints);
93+ } catch (e) {
94+ err.textContent = "Microphone access failed: " + (e && e.message ? e.message : e);
95+ return;
96+ }
97+ const track = stream.getAudioTracks()[0];
98+ const settings = track.getSettings ? track.getSettings() : {};
99+ // Creating the context after the stream exists gives iOS the input's sample rate.
100+ ctx = new (window.AudioContext || window.webkitAudioContext)();
101+ if (ctx.state === "suspended") await ctx.resume();
102+ sampleRate = ctx.sampleRate;
103+ source = ctx.createMediaStreamSource(stream);
104+ channels = wantStereo ? Math.min(2, source.channelCount || 1) : 1;
105+ chunks = []; nSamples = 0; peak = 0;
106+
107+ const onSamples = (arrays) => {
108+ // arrays: one Float32Array per channel, equal length
109+ const len = arrays[0].length;
110+ if (!recording) return;
111+ const inter = new Float32Array(len * channels);
112+ for (let c = 0; c < channels; c++) {
113+ const a = arrays[Math.min(c, arrays.length - 1)];
114+ for (let i = 0; i < len; i++) {
115+ const v = a[i];
116+ inter[i * channels + c] = v;
117+ const av = Math.abs(v);
118+ if (av > peak) peak = av;
119+ }
120+ }
121+ chunks.push(inter);
122+ nSamples += len;
123+ };
124+
125+ if (ctx.audioWorklet) {
126+ try {
127+ await ctx.audioWorklet.addModule("worklet.js");
128+ node = new AudioWorkletNode(ctx, "capture", { numberOfInputs: 1, numberOfOutputs: 1, channelCount: channels, channelCountMode: "explicit" });
129+ node.port.onmessage = (ev) => onSamples([ev.data]);
130+ // Stereo through the worklet needs per-channel data; use the fallback path for two channels.
131+ if (channels === 2) node = null;
132+ } catch (e) {
133+ node = null;
134+ }
135+ }
136+ if (!node) {
137+ const sp = ctx.createScriptProcessor(4096, channels, channels);
138+ sp.onaudioprocess = (ev) => {
139+ const arrays = [];
140+ for (let c = 0; c < channels; c++) arrays.push(ev.inputBuffer.getChannelData(c).slice());
141+ onSamples(arrays);
142+ };
143+ node = sp;
144+ }
145+ source.connect(node);
146+ // A silent sink keeps the processing graph alive without playing the mic back.
147+ const sink = ctx.createGain(); sink.gain.value = 0;
148+ node.connect(sink); sink.connect(ctx.destination);
149+
150+ recording = true;
151+ t0 = performance.now();
152+ btnRec.disabled = true; btnStop.disabled = false; btnRec.classList.add("rec"); btnRec.textContent = "Recording";
153+ info.textContent = `Recording at ${sampleRate} Hz, ${channels === 2 ? "stereo" : "mono"}.\nBrowser-reported input settings: ` +
154+ `echoCancellation=${settings.echoCancellation}, noiseSuppression=${settings.noiseSuppression}, autoGainControl=${settings.autoGainControl}` +
155+ (settings.sampleRate ? `, sampleRate=${settings.sampleRate}` : "") + (settings.deviceId ? "" : "") + "\n" +
156+ `Device: ${track.label || "unknown"}`;
157+ timer = setInterval(() => {
158+ timeEl.textContent = ((performance.now() - t0) / 1000).toFixed(1) + " s";
159+ const db = dbfs(peak);
160+ peakEl.textContent = `peak ${fmtDb(db)} dBFS`;
161+ peakEl.classList.toggle("clip", db > -0.5);
162+ bar.style.width = Math.max(0, Math.min(100, (db + 60) / 60 * 100)) + "%";
163+ }, 100);
164+ }
165+
166+ function stop() {
167+ if (!recording) return;
168+ recording = false;
169+ clearInterval(timer);
170+ try { source.disconnect(); node.disconnect(); } catch (e) {}
171+ if (stream) stream.getTracks().forEach((t) => t.stop());
172+ if (ctx) ctx.close();
173+ btnRec.disabled = false; btnStop.disabled = true; btnRec.classList.remove("rec"); btnRec.textContent = "Record";
174+ const total = new Float32Array(nSamples * channels);
175+ let o = 0;
176+ for (const c of chunks) { total.set(c, o); o += c.length; }
177+ chunks = [];
178+ const wav = encodeWav(total, sampleRate, channels);
179+ addTake(wav, nSamples / sampleRate, peak);
180+ }
181+
182+ function encodeWav(samples, rate, ch) {
183+ const bytes = 44 + samples.length * 2;
184+ const buf = new ArrayBuffer(bytes);
185+ const v = new DataView(buf);
186+ const str = (off, s) => { for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); };
187+ str(0, "RIFF"); v.setUint32(4, bytes - 8, true); str(8, "WAVE");
188+ str(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, ch, true);
189+ v.setUint32(24, rate, true); v.setUint32(28, rate * ch * 2, true); v.setUint16(32, ch * 2, true); v.setUint16(34, 16, true);
190+ str(36, "data"); v.setUint32(40, samples.length * 2, true);
191+ let off = 44;
192+ for (let i = 0; i < samples.length; i++, off += 2) {
193+ const s = Math.max(-1, Math.min(1, samples[i]));
194+ v.setInt16(off, s < 0 ? s * 32768 : s * 32767, true);
195+ }
196+ return new Blob([buf], { type: "audio/wav" });
197+ }
198+
199+ function stamp() {
200+ const d = new Date(), p = (n) => String(n).padStart(2, "0");
201+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
202+ }
203+
204+ function addTake(blob, seconds, pk) {
205+ takeCount++;
206+ const name = `take${takeCount}-${stamp()}.wav`;
207+ const url = URL.createObjectURL(blob);
208+ const div = document.createElement("div");
209+ div.className = "take";
210+ const nameEl = document.createElement("span"); nameEl.className = "name"; nameEl.textContent = name;
211+ const dl = document.createElement("a"); dl.className = "dl"; dl.href = url; dl.download = name; dl.textContent = "Download";
212+ const meta = document.createElement("span"); meta.className = "meta";
213+ meta.textContent = `${seconds.toFixed(1)} s, ${sampleRate} Hz, ${channels === 2 ? "stereo" : "mono"}, 16-bit, ${(blob.size / 1e6).toFixed(1)} MB, peak ${fmtDb(dbfs(pk))} dBFS${dbfs(pk) > -0.5 ? " (clipped)" : ""}`;
214+ const actions = document.createElement("div"); actions.className = "actions";
215+ const audio = document.createElement("audio"); audio.controls = true; audio.src = url; audio.preload = "metadata";
216+ if (navigator.canShare && navigator.canShare({ files: [new File([blob], name, { type: "audio/wav" })] })) {
217+ const share = document.createElement("button"); share.className = "small"; share.textContent = "Share / save to Files";
218+ share.onclick = () => navigator.share({ files: [new File([blob], name, { type: "audio/wav" })], title: name }).catch(() => {});
219+ actions.appendChild(share);
220+ }
221+ const del = document.createElement("button"); del.className = "small"; del.textContent = "Remove";
222+ del.onclick = () => { URL.revokeObjectURL(url); div.remove(); };
223+ actions.appendChild(del);
224+ div.append(nameEl, dl, meta, actions, audio);
225+ takesEl.prepend(div);
226+ }
227+
228+ btnRec.onclick = () => start().catch((e) => { err.textContent = String(e); });
229+ btnStop.onclick = stop;
230+ $("btnClear").onclick = () => { takesEl.textContent = ""; };
231+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
232+ err.textContent = "This browser does not offer microphone capture (the page must be served over https).";
233+ btnRec.disabled = true;
234+ }
235+ // Expose for tests.
236+ window.wavrec = { start, stop, takes: () => takesEl.children.length };
237+})();
238+</script>
239+</body>
240+</html>
worklet.jsadded+9−0View file
@@ -0,0 +1,9 @@
1+// Captures the raw input samples (channel 0) and posts them to the main thread.
2+class CaptureProcessor extends AudioWorkletProcessor {
3+ process(inputs) {
4+ const ch = inputs[0] && inputs[0][0];
5+ if (ch) this.port.postMessage(ch.slice());
6+ return true;
7+ }
8+}
9+registerProcessor("capture", CaptureProcessor);
↑↓ move↵openescclose