1<!DOCTYPE html> 2<html> 3 <head> 4 <meta charset="utf-8" /> 5 <title>AV Debugger</title> 6 <meta name="description" content="𦺠Debug Audio and Video inputs" /> 7 <meta name="theme-color" content="#00bcd4" /> 8 <link rel="icon" href="../icon.png" /> 9 <meta name="viewport" content="width=device-width, initial-scale=1" /> 10 <style> 11 body { 12 padding: 0; 13 margin: 0; 14 font-family: sans-serif; 15 } 16 body { 17 margin: 4px; 18 } 19 header { 20 background: tomato; 21 padding: 8px; 22 border-radius: 4px; 23 } 24 #warning { 25 color: tomato; 26 font-weight: 800; 27 } 28 aside { 29 display: inline-block; 30 --volume: 0%; 31 position: relative; 32 width: 200px; 33 height: 20px; 34 background-color: #ddd; 35 } 36 37 aside::before { 38 content: ''; 39 position: absolute; 40 top: 0; 41 bottom: 0; 42 left: 0; 43 width: var(--volume); 44 background-color: #00bcd4; 45 transition: width 100ms linear; 46 } 47 #vibrate { 48 padding: 16px; 49 } 50 </style> 51 </head> 52 <body> 53 <header hidden></header> 54 <h1>Media Player</h1> 55 <table> 56 <tr> 57 <td>Track:</td> 58 <td> 59 <audio id="mp" title="Media Player" controls loop> 60 <source src="../av/example.mp3" type="audio/mp3" /> 61 </audio> 62 </td> 63 </tr> 64 <tr> 65 <td>Output:</td> 66 <td> 67 <select id="mp-out" onchange="selectMPOut()"> 68 <option selected disabled>Loading...</option> 69 </select> 70 <span id="warning" hidden> 71 Output selection not supported in your browser 72 </span> 73 </td> 74 </tr> 75 </table> 76 77 <h1>Mic</h1> 78 <table> 79 <tr> 80 <td>Input:</td> 81 <td> 82 <select id="audio-in" onchange="selectAudioIn()"> 83 <option selected disabled>Loading...</option> 84 </select> 85 </td> 86 </tr> 87 <tr> 88 <td>Level:</td> 89 <td><aside></aside></td> 90 </tr> 91 <tr> 92 <td>FX Engine:</td> 93 <td> 94 Delay: 0<input 95 type="range" 96 min="0" 97 max="10" 98 value="1" 99 step="0.01" 100 />10 (seconds) 101 </td> 102 </tr> 103 <tr> 104 <td>Output:</td> 105 <td> 106 <select id="audio-out"> 107 <option selected disabled>(default output will be used)</option> 108 </select> 109 <button id="startAudioBtn" onclick="startAudioOut()">Start</button> 110 </td> 111 </tr> 112 </table> 113 114 <h1>Video Input</h1> 115 <table> 116 <tr> 117 <td>Quality:</td> 118 <td> 119 <select 120 id="video-quality" 121 onchange="localStorage.q = this.value;location.reload()" 122 > 123 <option>Default</option> 124 <option>Best</option> 125 </select> 126 (changing this reloads the page) 127 </td> 128 </tr> 129 <tr> 130 <td>Input:</td> 131 <td> 132 <select id="video-in" onchange="selectVideoIn()"> 133 <option selected disabled>Loading...</option> 134 </select> 135 </td> 136 </tr> 137 <tr> 138 <td>Resolution:</td> 139 <td id="video-res"></td> 140 </tr> 141 <tr> 142 <td>Preview:</td> 143 <td> 144 <video autoplay controls /> 145 </td> 146 </tr> 147 </table> 148 149 <button 150 id="vibrate" 151 onmousedown="navigator.vibrate(9999)" 152 onmouseup="navigator.vibrate(0)" 153 ontouchstart="navigator.vibrate(9999)" 154 ontouchend="navigator.vibrate(0)" 155 hidden 156 > 157 Vibrate 158 </button> 159 <footer></footer> 160 161
161<script> 162 /** @type {typeof document.querySelector} */ 163 const $ = (id) => document.querySelector(id); 164 165 if ('vibrate' in navigator) $('#vibrate').hidden = false; 166 const supportsSink = 'setSinkId' in new Audio(); 167 if (!supportsSink) $('#warning').hidden = false; 168 169 /** 170 * converts a decimal to a nice aspect ratio) 171 * from https://stackoverflow.com/a/14011299/5470183 172 */ 173 function niceAspectRatio(num) { 174 const eps = 1e-15; 175 let x = num; 176 let a = Math.floor(x); 177 let h1 = 1; 178 let k1 = 0; 179 let h = a; 180 let k = 1; 181 182 while (x - a > eps * k * k) { 183 x = 1 / (x - a); 184 a = Math.floor(x); 185 const h2 = h1; 186 h1 = h; 187 const k2 = k1; 188 k1 = k; 189 h = h2 + a * h1; 190 k = k2 + a * k1; 191 } 192 193 return `${h}:${k}`; 194 } 195 196 async function queryPermission() { 197 // not all browsers support this approach 198 try { 199 await navigator.permissions.request({ name: 'camera' }); 200 await navigator.permissions.request({ name: 'microphone' }); 201 } catch (ex) { 202 console.log('navigator.permissions.request failed', ex); 203 } 204 205 // this will try prompt for video consent 206 try { 207 await navigator.mediaDevices.getUserMedia({ 208 video: true, 209 audio: false, 210 }); 211 // this will prompt for audio consent 212 await navigator.mediaDevices.getUserMedia({ 213 video: false, 214 audio: true, 215 }); 216 } catch (ex) { 217 console.log('getUserMedia failed', ex); 218 } 219 220 try { 221 if (!navigator.permissions) throw new Error(); 222 223 const { state: camera } = await navigator.permissions.query({ 224 name: 'camera', 225 }); 226 const { state: mic } = await navigator.permissions.query({ 227 name: 'microphone', 228 }); 229 return { camera, mic }; 230 } catch { 231 return { camera: 'unknown', mic: 'unknown' }; 232 } 233 } 234 235 /** @param {MediaDeviceInfo} x @param {number} i*/ 236 const toOption = (x, i) => 237 `<option value="${x.deviceId}">${x.label || `Unnamed #${i}`}</option>`; 238 239 function onError(ex) { 240 $('header').innerHTML = `${ex}`; 241 $('header').hidden = false; 242 } 243 244 async function init() { 245 try { 246 const perm = await queryPermission(); 247 $('footer').innerHTML = `Camera: ${perm.camera}, Mic: ${perm.mic}`; 248 249 const devices = await navigator.mediaDevices.enumerateDevices(); 250 251 const list = [ 252 ...devices, 253 { kind: 'audioinput', deviceId: '--None', label: '⨠None' }, 254 { kind: 'audioinput', deviceId: '--MP1', label: '⨠Media Player' }, 255 ]; 256 257 $('#video-quality').value = localStorage.q || 'Default'; 258 259 $('#audio-in').innerHTML = list 260 .filter((x) => x.kind === 'audioinput') 261 .map(toOption) 262 .join(''); 263 await window.selectAudioIn(); 264 265 $('#video-in').innerHTML = list 266 .filter((x) => x.kind === 'videoinput') 267 .map(toOption) 268 .join(''); 269 await window.selectVideoIn(); 270 271 $('#mp-out').innerHTML = list 272 .filter((x) => x.kind === 'audiooutput') 273 .map(toOption) 274 .join(''); 275 await window.selectMPOut(); 276 277 // $('#audio-out').innerHTML = $('#mp-out').innerHTML; 278 } catch (ex) { 279 onError(ex); 280 } 281 } 282 283 navigator.mediaDevices.ondevicechange = init; 284 init(); 285 286 window.selectVideoIn = async () => { 287 const deviceId = $('#video-in').value; 288 const bestQuality = $('#video-quality').value === 'Best'; 289 290 const videoStream = await navigator.mediaDevices.getUserMedia({ 291 video: bestQuality 292 ? { deviceId, width: { ideal: 9999 }, height: { ideal: 9999 } } 293 : { deviceId }, 294 }); 295 296 const s = videoStream.getVideoTracks()[0].getSettings(); 297 298 $('video').srcObject = videoStream; 299 $('video').play(); 300 301 $('#video-res').innerHTML = `${s.width}x${s.height} (${niceAspectRatio( 302 s.aspectRatio 303 )}) @${s.frameRate}fps`; 304 }; 305 306 let audioLoop; 307 /** @type {MediaStreamAudioSourceNode} */ 308 let micSource; 309 window.selectAudioIn = async () => { 310 try { 311 const deviceId = $('#audio-in').value; 312 if (audioLoop) { 313 clearInterval(audioLoop); 314 micSource = null; 315 } 316 317 if (deviceId === '--None') { 318 $('aside').style.setProperty('--volume', '0%'); 319 return; 320 } 321 322 const audioStream = 323 deviceId === '--MP1' 324 ? $('audio#mp').captureStream() 325 : await navigator.mediaDevices.getUserMedia({ 326 audio: { deviceId }, 327 }); 328 329 const audioCtx = new AudioContext(); 330 window.audioCtx = audioCtx; 331 const anal = audioCtx.createAnalyser(); 332 anal.fftSize = 512; 333 anal.minDecibels = -127; 334 anal.maxDecibels = 0; 335 anal.smoothingTimeConstant = 0.4; 336 const range = anal.maxDecibels - anal.minDecibels; 337 338 micSource = audioCtx.createMediaStreamSource(audioStream); 339 micSource.connect(anal); 340 341 const levelChunks = new Uint8Array(anal.frequencyBinCount); 342 343 const el = $('aside'); 344 audioLoop = setInterval(() => { 345 anal.getByteFrequencyData(levelChunks); 346 let level = 0; 347 for (const chunk of levelChunks) level += chunk; 348 const averageVolume = level / levelChunks.length; 349 const percent = `${(averageVolume / range) * 100}%`; 350 el.style.setProperty('--volume', percent); 351 }, 100); 352 } catch (ex) { 353 onError(ex); 354 } 355 }; 356 357 window.selectMPOut = async () => { 358 try { 359 if (!supportsSink) return; 360 361 const deviceId = $('#mp-out').value; 362 await $('audio#mp').setSinkId(deviceId); 363 } catch (ex) { 364 onError(ex); 365 } 366 }; 367 368 window.startAudioOut = async () => { 369 try { 370 const { audioCtx } = window; 371 if (!audioCtx) { 372 // eslint-disable-next-line no-alert 373 alert('No audio feed from input'); 374 return; 375 } 376 377 if ($('#audio-in').value === '--None') { 378 // eslint-disable-next-line no-alert 379 alert('Select an audio input'); 380 return; 381 } 382 if ($('#audio-in').value === '--MP1') { 383 // eslint-disable-next-line no-restricted-globals, no-alert 384 const shouldContinue = confirm(
385 'Note that the Media Player is routed to the output before and after the FX Engine\n\nYou also need to press play in the Media Player' 386 ); 387 if (!shouldContinue) return; 388 } 389 390 const startStopBtn = $('#startAudioBtn'); 391 startStopBtn.onclick = () => window.location.reload(); 392 startStopBtn.innerHTML = 'Stop'; 393 394 const delaySeconds = +$('input[type=range]').value; 395 396 const delay = audioCtx.createDelay(10); 397 delay.delayTime.value = delaySeconds; 398 micSource.connect(delay); 399 delay.connect(audioCtx.destination); 400 401 const audioEl = new Audio(); 402 audioEl.srcObject = audioCtx.createMediaStreamDestination().stream; 403 // audioEl.setSinkId($('#audio-out').value); // this isn't supported yet by any browser 404 audioEl.play(); 405 } catch (ex) { 406 onError(ex); 407 } 408 }; 409 </script>
409 410
410<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"b10fbfa1a5974fda80cf8c07cdf2c60c","r":1,"spa":2}' crossorigin="anonymous"></script>
410 411</body> 412</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.