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>
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>
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>
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;
75 function dbfs(x) { return x > 0 ? 20 * Math.log10(x) : -Infinity; }
76 function fmtDb(x) { return isFinite(x) ? x.toFixed(1) : "-inf"; }
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;
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 };
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);
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 }
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 }
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 }
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 }
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 }
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>