Last active
September 17, 2017 04:56
-
-
Save HelloWorld017/bdefb8c745a29d780f0d934462120b07 to your computer and use it in GitHub Desktop.
Oscillator-Oscilloscope
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>Oscillator-Oscilloscope</title> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.100.2/css/materialize.min.css"> | |
| <link rel="stylesheet" href="https://cdn.rawgit.com/HelloWorld017/NanumBarunGothic/master/nanumbarungothic.css"> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, intial-scale=2, user-scalable=no"> | |
| <style> | |
| .range-box { | |
| display: flex; | |
| } | |
| .range-box>.range-field { | |
| flex: 1; | |
| margin: 10px; | |
| display: flex; | |
| } | |
| body.dark { | |
| background: #222; | |
| } | |
| body.dark h3, body.dark input { | |
| color: #f1f2f3; | |
| } | |
| body.dark input:disabled, body.dark input:disabled+label { | |
| color: rgba(255, 255, 255, 0.42) !important; | |
| } | |
| body.dark input:disabled { | |
| border-bottom: 1px dotted rgba(255, 255, 255, 255.42) !important; | |
| } | |
| body.dark .modal { | |
| background: #303030; | |
| } | |
| body.dark .modal-footer { | |
| background: #353535; | |
| } | |
| body.dark .modal-footer .modal-action { | |
| color: #f1f2f3; | |
| } | |
| h3 { | |
| font-size: 1.5rem; | |
| font-weight: 200; | |
| } | |
| * { | |
| font-family: 'Nanum Barun Gothic', sans-serif; | |
| } | |
| *::-webkit-scrollbar-track { | |
| background-color: transparent; | |
| } | |
| *::-webkit-scrollbar { | |
| width: 20px; | |
| background-color: transparent; | |
| } | |
| *::-webkit-scrollbar-thumb { | |
| background-color: #009688; | |
| background-image: -webkit-linear-gradient(45deg, | |
| #4db6ac 25%, | |
| transparent 25%, | |
| transparent 50%, | |
| #4db6ac 50%, | |
| #4db6ac 75%, | |
| transparent 75%, | |
| transparent) | |
| } | |
| body { | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| } | |
| main { | |
| padding: 30px; | |
| display: flex; | |
| width: 100%; | |
| flex: 1; | |
| box-sizing: border-box; | |
| } | |
| aside { | |
| display: flex; | |
| flex: 1; | |
| flex-direction: column; | |
| padding-left: 30px; | |
| justify-content: center; | |
| } | |
| form { | |
| padding-right: 30px; | |
| } | |
| .controller { | |
| flex: 2; | |
| max-width: 768px; | |
| overflow: auto; | |
| } | |
| input[type=range] { | |
| border: none; | |
| } | |
| .play{ | |
| display: flex; | |
| justify-content: center; | |
| margin-top: 50px; | |
| } | |
| .play button { | |
| width: 30%; | |
| min-width: 100px; | |
| height: 50px; | |
| margin: 10px; | |
| } | |
| canvas { | |
| width: 100%; | |
| height: 300px; | |
| } | |
| p { | |
| color: #f1f2f3; | |
| text-align: center; | |
| } | |
| *::selection { | |
| background: #009688; | |
| } | |
| @media (max-width: 1150px) { | |
| .controller { | |
| overflow: initial; | |
| max-width: initial; | |
| flex: initial; | |
| } | |
| body { | |
| height: initial; | |
| } | |
| aside { | |
| flex: initial; | |
| display: block; | |
| } | |
| canvas { | |
| height: 400px; | |
| } | |
| main { | |
| flex-direction: column; | |
| } | |
| form { | |
| padding: 0; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body class="dark"> | |
| <nav> | |
| <div class="teal nav-wrapper"> | |
| <div class="container"> | |
| <span class="brand-logo">Oscillator-Oscilloscope</span> | |
| <ul id="nav-mobile" class="right hide-on-med-and-down"> | |
| <li><a href="https://gist.github.com/HelloWorld017/bdefb8c745a29d780f0d934462120b07">소스 보기</a></li> | |
| <li><a href="https://khinenw.tk">개발자 정보</a></li> | |
| </ul> | |
| </div> | |
| </div> | |
| </nav> | |
| <main> | |
| <section class="controller"> | |
| <form> | |
| <section class="section shape"> | |
| <h3>파형</h3> | |
| <p> | |
| <input name="shape" type="radio" id="sine" value="sine" checked> | |
| <label for="sine">사인파</label> | |
| </p> | |
| <p> | |
| <input name="shape" type="radio" id="square" value="square"> | |
| <label for="square">사각파</label> | |
| </p> | |
| <p> | |
| <input name="shape" type="radio" id="sawtooth" value="sawtooth"> | |
| <label for="sawtooth">톱니파</label> | |
| </p> | |
| <p> | |
| <input name="shape" type="radio" id="triangle" value="triangle"> | |
| <label for="triangle">삼각파</label> | |
| </p> | |
| <p> | |
| <input name="shape" type="radio" id="custom" value="custom"> | |
| <label for="custom">푸리에 급수</label> | |
| <br><br> | |
| <div class="input-field fourier-text"> | |
| <input | |
| id="custom-text-real" | |
| class="validate" | |
| type="text" | |
| value="0, 0, 0, 0, 0" | |
| disabled> | |
| <label | |
| data-error="실수부와 허수부의 크기가 같지 않거나 숫자와 컴마로 이루어져 있지 않습니다." | |
| for="custom-text-real"> | |
| 푸리에 급수 실수부 (a_0, a_1, a_2, ...) | |
| </label> | |
| </div> | |
| <div class="input-field fourier-text"> | |
| <input | |
| id="custom-text-imag" | |
| class="validate" | |
| type="text" | |
| value="0, 1, 0, 0, 0" | |
| disabled> | |
| <label | |
| data-error="실수부와 허수부의 크기가 같지 않거나 숫자와 컴마로 이루어져 있지 않습니다." | |
| for="custom-text-imag"> | |
| 푸리에 급수 허수부 (0, b_1, b_2, b_3, ...) | |
| </label> | |
| </div> | |
| </p> | |
| </section> | |
| <section class="section frequency"> | |
| <h3>진동수</h3> | |
| <div class="range-box"> | |
| <p class="range-field"> | |
| <input type="range" id="freq" min="1" max="24000" value="2000"/> | |
| </p> | |
| <div class="input-field inline"> | |
| <input id="freq-text" type="number" value="2000"> | |
| <label for="freq-text">진동수 (Hz)</label> | |
| </div> | |
| <a class="btn waves-effect waves-light modal-trigger" id="freq-sweep" href="#freq-sweep-modal"> | |
| 진동수 증가 | |
| </a> | |
| </div> | |
| </section> | |
| <section class="section amplitude"> | |
| <h3>진폭</h3> | |
| <div class="range-box"> | |
| <p class="range-field"> | |
| <input type="range" id="amp" min="-100" max="0" value="0"/> | |
| </p> | |
| <div class="input-field inline"> | |
| <input id="amp-text" type="number" value="0"> | |
| <label for="amp-text">진폭 (dBFS)</label> | |
| </div> | |
| <a class="btn waves-effect waves-light modal-trigger" id="amp-sweep" href="#amp-sweep-modal"> | |
| 진폭 증가 | |
| </a> | |
| </div> | |
| </section> | |
| <section class="section play"> | |
| <button id="play" class="btn waves-effect waves-light" type="button">재생</button> | |
| <button id="osc-pause" class="btn waves-effect waves-light" type="button">오실로스코프 일시정지</button> | |
| </section> | |
| <section class="section play"> | |
| <button id="sample-play" class="btn waves-effect waves-light" type="button">샘플 음원 재생</button> | |
| </section> | |
| </form> | |
| </section> | |
| <aside> | |
| <canvas></canvas> | |
| <p>이 오실로스코프는 샘플링 개수에 한계가 있기 때문에 (특히 진동수가 높아질 수록) 부정확합니다.</p> | |
| </aside> | |
| </main> | |
| <section id="freq-sweep-modal" class="modal"> | |
| <div class="modal-content"> | |
| <h3>진동수 증가 (Sweep)</h3> | |
| <div class="input-field"> | |
| <input id="freq-sweep-start" type="number" value="20"> | |
| <label for="freq-sweep-start">시작</label> | |
| </div> | |
| <div class="input-field"> | |
| <input id="freq-sweep-speed" type="number" value="1000"> | |
| <label for="freq-sweep-speed">Hz/s</label> | |
| </div> | |
| <div class="input-field"> | |
| <input id="freq-sweep-end" type="number" value="20000"> | |
| <label for="freq-sweep-end">끝</label> | |
| </div> | |
| </div> | |
| <div class="modal-footer"> | |
| <a href="#!" id="freq-start" class="modal-action modal-close waves-effect waves-teal btn-flat">시작</a> | |
| <a href="#!" class="modal-action modal-close waves-effect waves-orange btn-flat">취소</a> | |
| </div> | |
| </section> | |
| <section id="amp-sweep-modal" class="modal"> | |
| <div class="modal-content"> | |
| <h3>진폭 증가 (Sweep)</h3> | |
| <div class="input-field"> | |
| <input id="amp-sweep-start" type="number" value="0"> | |
| <label for="amp-sweep-start">시작</label> | |
| </div> | |
| <div class="input-field"> | |
| <input id="amp-sweep-speed" type="number" value="-1"> | |
| <label for="amp-sweep-speed">dBFS/s</label> | |
| </div> | |
| <div class="input-field"> | |
| <input id="amp-sweep-end" type="number" value="-20"> | |
| <label for="amp-sweep-end">끝</label> | |
| </div> | |
| </div> | |
| <div class="modal-footer"> | |
| <a href="#!" id="amp-start" class="modal-action modal-close waves-effect waves-teal btn-flat">시작</a> | |
| <a href="#!" class="modal-action modal-close waves-effect waves-orange btn-flat">취소</a> | |
| </div> | |
| </section> | |
| <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.100.2/js/materialize.min.js"></script> | |
| <script src="https://unpkg.com/babel-transform-in-browser@6.4.6/dist/btib.min.js"></script> | |
| <script type="text/es2015"> | |
| const $ = document.querySelector.bind(document); | |
| const $$ = document.querySelectorAll.bind(document); | |
| if(!AudioContext) alert('웹 오디오 API를 지원하지 않는 브라우저 입니다! 최신버전의 크롬을 이용해주세요.'); | |
| const context = new AudioContext; | |
| const sampleAudio = new Audio; | |
| const sample = context.createMediaElementSource(sampleAudio); | |
| const oscillator = context.createOscillator(); | |
| const gain = context.createGain(); | |
| const analyser = context.createAnalyser(); | |
| sampleAudio.src = 'https://cdn.rawgit.com/HelloWorld017/restaurance/master/resources/storm-best.mp3'; | |
| sampleAudio.crossOrigin = "anonymous"; | |
| oscillator.frequency.value = 2000; | |
| oscillator.type = 'sine'; | |
| oscillator.start(); | |
| gain.gain.value = 1; | |
| analyser.fftSize = 2048; | |
| sample.connect(gain); | |
| gain.connect(analyser); | |
| analyser.connect(context.destination); | |
| let count = 0; | |
| let resizeReserved = false; | |
| const drawWave = (ctx) => { | |
| const len = analyser.frequencyBinCount; | |
| const buffer = new Float32Array(len); | |
| const w = ctx.canvas.width; | |
| const h = ctx.canvas.height; | |
| const ratioX = 3; | |
| const ratioY = 100; | |
| ctx.strokeStyle = "#009688"; | |
| ctx.setTransform(1, 0, 0, -1, 0, h / 2); | |
| ctx.lineWidth = 2; | |
| resizeReserved = false; | |
| const loop = () => { | |
| if(oscPlaying) { | |
| analyser.getFloatTimeDomainData(buffer); | |
| ctx.clearRect(0, -h/2, w, h); | |
| ctx.beginPath(); | |
| ctx.moveTo(0, buffer[0] * ratioY); | |
| for (let x = 1; x < len; x++) { | |
| const calculatedX = x * ratioX; | |
| if(calculatedX > w) continue; | |
| ctx.lineTo(calculatedX, buffer[x] * ratioY); | |
| } | |
| ctx.stroke(); | |
| } | |
| if(!resizeReserved) requestAnimationFrame(loop); | |
| else drawWave(ctx); | |
| }; | |
| loop(); | |
| }; | |
| let isPlaying = false; | |
| let oscPlaying = true; | |
| const togglePlay = () => { | |
| if(isPlaying) { | |
| oscillator.disconnect(gain); | |
| $('#play').innerText = '재생'; | |
| } else { | |
| oscillator.connect(gain); | |
| $('#play').innerText = '정지'; | |
| } | |
| isPlaying = !isPlaying; | |
| }; | |
| const toggleOsc = () => { | |
| if(oscPlaying) { | |
| $('#osc-pause').innerText = '오실로스코프 다시시작'; | |
| } else { | |
| $('#osc-pause').innerText = '오실로스코프 일시정지'; | |
| } | |
| oscPlaying = !oscPlaying; | |
| }; | |
| const updateShape = () => { | |
| const shape = $('input[name="shape"]:checked').value; | |
| const customText = $$('.fourier-text input'); | |
| if(shape === 'custom') { | |
| customText.forEach((v) => v.disabled = null); | |
| const imagRaw = $('#custom-text-imag').value.split(','); | |
| imagRaw.map(i => parseFloat(i.trim())); | |
| const realRaw = $('#custom-text-real').value.split(','); | |
| realRaw.map(i => parseFloat(i.trim())); | |
| if(imagRaw.concat(realRaw).some(v => isNaN(v)) || imagRaw.length !== realRaw.length) { | |
| customText.forEach(v => { | |
| v.classList.remove('valid'); | |
| v.classList.add('invalid'); | |
| }); | |
| } else { | |
| oscillator.setPeriodicWave(context.createPeriodicWave(realRaw, imagRaw)); | |
| customText.forEach(v => { | |
| v.classList.remove('invalid'); | |
| v.classList.add('valid'); | |
| }); | |
| } | |
| } else { | |
| customText.forEach((v) => v.disabled = 'disabled'); | |
| oscillator.type = shape; | |
| } | |
| }; | |
| const updateAmp = (val) => { | |
| gain.gain.value = Math.pow(10, val / 20); | |
| }; | |
| const updateFreq = (val) => oscillator.frequency.value = val; | |
| const bindTextRange = ({name, callback}) => { | |
| const range = $(`#${name}`); | |
| const text = $(`#${name}-text`); | |
| const cb = (...args) => { | |
| Materialize.updateTextFields(); | |
| callback(...args); | |
| }; | |
| range.addEventListener('input', () => { | |
| text.value = range.value; | |
| cb(range.value); | |
| }); | |
| text.addEventListener('input', () => { | |
| range.value = text.value; | |
| cb(text.value); | |
| }); | |
| return {name, callback}; | |
| }; | |
| const sweep = ({name, callback}) => { | |
| const range = $(`#${name}`); | |
| const text = $(`#${name}-text`); | |
| const button = $(`#${name}-start`); | |
| let current, speed, end, startDiff, sweeping; | |
| const reset = () => { | |
| current = parseFloat($(`#${name}-sweep-start`).value); | |
| speed = parseFloat($(`#${name}-sweep-speed`).value) / 20; | |
| end = parseFloat($(`#${name}-sweep-end`).value); | |
| startDiff = Math.sign(end - current); | |
| sweeping = false; | |
| button.innerText = "시작"; | |
| }; | |
| const start = () => { | |
| setTimeout(() => { | |
| sweeping = true; | |
| button.innerText = "정지"; | |
| sweepStep(); | |
| }, 75); | |
| }; | |
| const cb = (...args) => { | |
| Materialize.updateTextFields(); | |
| callback(...args); | |
| }; | |
| const sweepStep = () => { | |
| if(!sweeping) return; | |
| current += speed; | |
| range.value = text.value = current; | |
| cb(current); | |
| if(sweeping && startDiff === Math.sign(end - current)) setTimeout(sweepStep, 50); | |
| }; | |
| button.addEventListener('click', () => { | |
| if(sweeping) return reset(); | |
| reset(); | |
| start(); | |
| }); | |
| }; | |
| const canvas = $('canvas'); | |
| const calcCanvas = () => { | |
| const rect = canvas.getBoundingClientRect(); | |
| [canvas.width, canvas.height] = [rect.width, rect.height]; | |
| resizeReserved = true; | |
| }; | |
| $('#play').addEventListener('click', togglePlay); | |
| $('#osc-pause').addEventListener('click', toggleOsc); | |
| $('#sample-play').addEventListener('click', () => { | |
| if(count === 1) sampleAudio.src = 'https://cdn.rawgit.com/HelloWorld017/restaurance/master/resources/storm-love.mp3'; | |
| sampleAudio.play(); | |
| count++; | |
| }); | |
| window.addEventListener('resize', () => { | |
| calcCanvas(); | |
| }); | |
| calcCanvas(); | |
| drawWave(canvas.getContext('2d')); | |
| [...$$('.fourier-text input')] | |
| .concat([...$$('input[name="shape"]')]) | |
| .forEach((e) => e.addEventListener('change', updateShape)); | |
| $$('.fourier-text input[type="text"]').forEach(v => v.addEventListener('blur', updateShape)); | |
| sweep(bindTextRange({ | |
| name: 'amp', | |
| callback: updateAmp | |
| })); | |
| sweep(bindTextRange({ | |
| name: 'freq', | |
| callback: updateFreq | |
| })); | |
| jQuery('.modal').modal(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment