使用JavaScript AudioContext相关API实现的音乐播放,同步显示歌词(LRC),频谱以及进度显示和改变播放进度等。
其中进度相关的实现感觉还是不太对路,暂时也没有查到对路的方法。不清楚为什么context.currentTime不是buffer实时的currentTime。
对于这个问题,后面BufferSource换用HTML5 Audio标签(mediaElement)做为AudioContext就简单多了。。。应该是两者的应用场景不同,我还没搞清楚吧。
注意:以下内容是 createBufferSource() 的DEMO,最后面附上了Audio标签的实现
Demo的在线尝试: 再探Audio 同步歌词,频谱,进度条控制
没有教程,没得力气写了,只有实例,DEMO效果长这样:
完整代码结构:
HTML:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Life & Music</title> <link rel="stylesheet" href="./style/index.css"> </head> <body> <div class="screen"> <h1>别错过</h1> <article> <div class="play load"></div> <div class="lrc"> <p class="before"></p> <p class="current"></p> <p class="next"></p> </div> </article> <div class="processbar"> <div class="processbar-side"></div> </div> </div> <canvas></canvas> <script src="./script/music.js"></script> </body> </html>
CSS:
html, body { margin: 0; height: 100%; overflow: hidden; background-color: #1d283d; background-image: linear-gradient(45deg, #1d283d, #15443e); } * { margin: 0; padding: 0; color: #e3f9ff; } body { position: relative; } .screen { position: relative; height: 100%; z-index: 2; display: flex; flex-direction: column; position: relative; } h1 { text-align: center; line-height: 100px; } @keyframes heart { 0% { transform: scale(1, 1) rotate(-180deg); } 40% { transform: scale(3, 1) rotate(180deg); } 100% { transform: scale(1, 1) rotate(-180deg); } } article { flex: 1; text-align: center; overflow: hidden; position: relative; display: flex; align-items: center; justify-content: center; } article .play { position: absolute; width: 100px; aspect-ratio: 1/1; border-radius: 50%; background-color: rgba(255, 255, 255, 0.048); left: 50%; top: 50%; transform: translate3d(-50%, -50%, 0); z-index: 2; overflow: hidden; } article .play.load::before { content: ""; left: -20px; right: -20px; height: 100%; top: 45%; background-color: rgba(37, 128, 131, 0.3); position: absolute; z-index: -1; border-radius: 50%; animation: heart 3s infinite; } article .play.loaded { cursor: pointer; } article .play.loaded:hover { box-shadow: 0 4px 8px rgba(18, 52, 56, 0.3); } article .play.loaded::before { content: ""; position: absolute; height: 1px; width: 1px; border: 30px solid #fff; border-width: 20px 30px 20px 30px; border-color: transparent transparent transparent rgba(187, 245, 255, 0.5); top: 28px; left: 41px; } article p { line-height: 60px; } article .lrc .before, article .lrc .next { opacity: 0.5; } article .lrc .current { font-size: 20px; } .processbar { position: absolute; left: 0; right: 0; bottom: 0; height: 10px; background-color: rgba(255, 255, 255, 0.1); } .processbar:hover { background-color: rgba(255, 255, 255, 0.2); } .processbar .processbar-side { height: 100%; width: 0%; background-color: rgba(0, 217, 255, 0.1); border-radius: 0 20px 20px 0; transition: 0.2s all linear; } .processbar .processbar-side:hover { background-color: rgba(0, 217, 255, 0.8); } canvas { width: 100%; height: 100%; position: absolute; left: 0; top: 0; right: 0; bottom: 0; z-index: 0; }
JavaScript:
const LRC = ` [00:00.00]程jiajia - 别错过 [00:00.15]作词:程jiajia [00:00.30]作曲:程jiajia [00:00.45]编曲:余威 [00:00.55]制作人:颜小健 @Jyken [00:00.80]配唱制作人:吕宏斌 [00:01.10]和声:赫拉Hera [00:01.26]吉他:LZY [00:01.41]录音师:李妙心、候天宇 [00:01.71]混音师:裴东文 [00:01.91]母带后期工程师:裴东文 [00:02.27]混音、母带后期录音室:18 Music Studio [00:02.67]推广:张作全@鲸鱼向海 [00:03.03]统筹:锦书 [00:03.18]监制:潇喆Sean [00:03.38]录音棚:Hikoon Music、莫非录音棚(成都) [00:03.78]OP、发行:鲸鱼向海(北京)文化有限公司 [00:04.34]「未经许可 不得翻唱翻录或使用」 [00:18.57]把你的心给我 [00:20.85]把你的爱给我 [00:23.28]这样我才能大胆尝试 [00:25.91]有更多的把握 [00:27.99]我要的也不多 [00:30.41]你不要嫌我啰嗦 [00:32.84]我只是十分害怕 [00:34.91]不小心与你错过 [00:37.94]我们辗转几何 [00:39.96]可结果又是如何 [00:42.65]没有任何意义 [00:44.83]其实你根本没爱过我 [00:47.20]脑袋空白在此刻 [00:50.24]我写了这首歌 [00:52.21]其实没什么舍不得 [00:54.64]只是眼睛酸涩全是红色 [01:16.09]把你的心给我 [01:18.42]把你的爱给我 [01:20.79]这样我才能大胆尝试 [01:23.52]有更多的把握 [01:25.59]我要的也不多 [01:27.98]你不要嫌我啰嗦 [01:30.40]我只是十分害怕 [01:32.47]不小心与你错过 [01:35.21]无处不在的难过 [01:37.89]又是谁的过错 [01:40.27]就此和你别过 [01:42.54]你会不会快乐 [01:44.87]这次我终于解脱 [01:47.75]等到了这一刻 [01:49.83]其实没什么舍不得 [01:52.21]好好学会得过且过 [02:04.34]我们辗转几何 [02:06.42]可结果又是如何 [02:09.10]没有任何意义 [02:11.17]其实你根本没爱过我 [02:13.60]脑袋空白在此刻 [02:16.63]我写了这首歌 [02:18.61]其实没什么舍不得 [02:21.03]只是眼睛酸涩全是红色 ` const LRCARR = LRC.split('\n') LRCARR.pop() LRCARR.shift() // header class Music{ constructor(canvas) { this.initCanvas(canvas) this.initMusic() this.start = 0 this.startCurrent = 0 } initCanvas(canvas) { this.canvas = canvas this.canvas.width = document.body.clientWidth this.canvas.height = document.body.clientHeight this.ctx = canvas.getContext('2d') this.ctx.fillStyle = '#1f5c55' } initMusic() { const musicSrc = '../music/bcg.mp3' const request = new XMLHttpRequest() request.responseType = 'arraybuffer' request.open('GET', musicSrc, true) request.onload = () => { document.querySelector('.play').className = 'play loaded' this.data = request.response this.playMusic(request.response) } request.send() } playMusic(data, duration = 0) { this.context = new AudioContext() this.analyser = this.context.createAnalyser() this.analyser.fftSize = 1024 this.source = this.context.createBufferSource() this.source.connect(this.analyser) this.analyser.connect(this.context.destination) this.context.decodeAudioData(data, buffer => { this.buffer = buffer this.source.buffer = buffer this.source.loop = true const playDom = document.querySelector('.play') playDom.addEventListener('click', () => { playDom.style.display = 'none' this.source.start(0, duration) this.animate() }) }) document.querySelector('.processbar').addEventListener('click', event => { this.setDuration(event.clientX / this.canvas.width) }) } setDuration(duration = 0.0) { if (this.source) { this.source.stop() } this.source = this.context.createBufferSource() this.source.connect(this.analyser) this.source.buffer = this.buffer this.source.loop = true const currentTime = this.source.buffer.duration * duration this.source.start(0, currentTime) this.start = this.context.currentTime this.startCurrent = currentTime } setLrc(currentTime) { currentTime = currentTime % this.source.buffer.duration let text = ['', '', ''] LRCARR.forEach((line, index) => { const time = line.substring(1,9).split(':') const durtion = parseInt(time[0]) * 60 + Number(time[1]) if (currentTime > durtion) { text[0] = (LRCARR[index - 1] ?? '').substring(10) text[1] = line.substring(10) text[2] = (LRCARR[index + 1] ?? '').substring(10) } }) document.querySelector('.before').innerHTML = text[0] document.querySelector('.current').innerHTML = text[1] document.querySelector('.next').innerHTML = text[2] } animate() { this.render() this.interval = requestAnimationFrame(() => { this.animate() }) } render() { const width = this.canvas.width const height = this.canvas.height const length = this.analyser.frequencyBinCount*44100 / this.context.sampleRate|0 const arrBuffer = new Uint8Array(length) this.analyser.getByteFrequencyData(arrBuffer) const BAR_WIDTH = 1 const BAR_SPACE = 50 const BAR_COUNT = Math.floor((width + BAR_SPACE) / (BAR_WIDTH + BAR_SPACE)) this.ctx.clearRect(0, 0, width, height) for(let i = 0; i < BAR_COUNT; i++) { const index = Math.floor(arrBuffer.length / BAR_COUNT * i * 0.5) const barHeight = arrBuffer[index] / 255 * height this.ctx.beginPath() this.ctx.rect(i * (BAR_WIDTH + BAR_SPACE), 0, BAR_WIDTH, barHeight * 0.5) this.ctx.closePath() this.ctx.fill() } const currentTime = this.context.currentTime - this.start + this.startCurrent const duration = currentTime / this.source.buffer.duration * 100 % 100 document.querySelector('.processbar-side').style.width = duration + '%' // document.querySelector('.processbar-side').innerHTML = currentTime this.setLrc(currentTime) } } const music = new Music(document.querySelector('canvas'))
附:后面又尝试用Audio,其它部分不变,JS部分为:
const LRC = ` [00:00.00]程jiajia - 别错过 [00:00.15]作词:程jiajia [00:00.30]作曲:程jiajia [00:00.45]编曲:余威 [00:00.55]制作人:颜小健 @Jyken [00:00.80]配唱制作人:吕宏斌 [00:01.10]和声:赫拉Hera [00:01.26]吉他:LZY [00:01.41]录音师:李妙心、候天宇 [00:01.71]混音师:裴东文 [00:01.91]母带后期工程师:裴东文 [00:02.27]混音、母带后期录音室:18 Music Studio [00:02.67]推广:张作全@鲸鱼向海 [00:03.03]统筹:锦书 [00:03.18]监制:潇喆Sean [00:03.38]录音棚:Hikoon Music、莫非录音棚(成都) [00:03.78]OP、发行:鲸鱼向海(北京)文化有限公司 [00:04.34]「未经许可 不得翻唱翻录或使用」 [00:18.57]把你的心给我 [00:20.85]把你的爱给我 [00:23.28]这样我才能大胆尝试 [00:25.91]有更多的把握 [00:27.99]我要的也不多 [00:30.41]你不要嫌我啰嗦 [00:32.84]我只是十分害怕 [00:34.91]不小心与你错过 [00:37.94]我们辗转几何 [00:39.96]可结果又是如何 [00:42.65]没有任何意义 [00:44.83]其实你根本没爱过我 [00:47.20]脑袋空白在此刻 [00:50.24]我写了这首歌 [00:52.21]其实没什么舍不得 [00:54.64]只是眼睛酸涩全是红色 [01:16.09]把你的心给我 [01:18.42]把你的爱给我 [01:20.79]这样我才能大胆尝试 [01:23.52]有更多的把握 [01:25.59]我要的也不多 [01:27.98]你不要嫌我啰嗦 [01:30.40]我只是十分害怕 [01:32.47]不小心与你错过 [01:35.21]无处不在的难过 [01:37.89]又是谁的过错 [01:40.27]就此和你别过 [01:42.54]你会不会快乐 [01:44.87]这次我终于解脱 [01:47.75]等到了这一刻 [01:49.83]其实没什么舍不得 [01:52.21]好好学会得过且过 [02:04.34]我们辗转几何 [02:06.42]可结果又是如何 [02:09.10]没有任何意义 [02:11.17]其实你根本没爱过我 [02:13.60]脑袋空白在此刻 [02:16.63]我写了这首歌 [02:18.61]其实没什么舍不得 [02:21.03]只是眼睛酸涩全是红色 ` const LRCARR = LRC.split('\n') LRCARR.pop() LRCARR.shift() // header class Music{ constructor(canvas) { this.audio = new Audio() this.initCanvas(canvas) this.initMusic() } initCanvas(canvas) { this.canvas = canvas this.canvas.width = document.body.clientWidth this.canvas.height = document.body.clientHeight this.ctx = canvas.getContext('2d') this.ctx.fillStyle = '#1f5c55' } initMusic() { const musicSrc = '../music/bcg.mp3' this.audio.oncanplay = () => { document.querySelector('.play').className = 'play loaded' const playDom = document.querySelector('.play') playDom.addEventListener('click', () => { playDom.style.display = 'none' this.playMusic() this.animate() }) } this.audio.src = musicSrc } playMusic(duration = 0) { this.context = new AudioContext() this.analyser = this.context.createAnalyser() this.analyser.fftSize = 1024 this.source = this.context.createMediaElementSource(this.audio) this.source.connect(this.analyser) this.analyser.connect(this.context.destination) document.querySelector('.processbar').addEventListener('click', event => { this.setDuration(event.clientX / this.canvas.width) }) this.source.mediaElement.play(duration) } setDuration(duration = 0.0) { const currentTime = this.source.mediaElement.duration * duration this.source.mediaElement.currentTime= currentTime } setLrc(currentTime) { currentTime = currentTime % this.source.mediaElement.duration let text = ['', '', ''] LRCARR.forEach((line, index) => { const time = line.substring(1,9).split(':') const durtion = parseInt(time[0]) * 60 + Number(time[1]) if (currentTime > durtion) { text[0] = (LRCARR[index - 1] ?? '').substring(10) text[1] = line.substring(10) text[2] = (LRCARR[index + 1] ?? '').substring(10) } }) document.querySelector('.before').innerHTML = text[0] document.querySelector('.current').innerHTML = text[1] document.querySelector('.next').innerHTML = text[2] } animate() { this.render() this.interval = requestAnimationFrame(() => { this.animate() }) } render() { const width = this.canvas.width const height = this.canvas.height const length = this.analyser.frequencyBinCount*44100 / this.context.sampleRate|0 const arrBuffer = new Uint8Array(length) this.analyser.getByteFrequencyData(arrBuffer) const BAR_WIDTH = 5 const BAR_SPACE = 10 const BAR_COUNT = Math.floor((width + BAR_SPACE) / (BAR_WIDTH + BAR_SPACE)) this.ctx.clearRect(0, 0, width, height) for(let i = 0; i < BAR_COUNT; i++) { const index = Math.floor(arrBuffer.length / BAR_COUNT * i * 0.5) const barHeight = arrBuffer[index] / 255 * height this.ctx.beginPath() this.ctx.rect(i * (BAR_WIDTH + BAR_SPACE), 0, BAR_WIDTH, barHeight * 0.5) this.ctx.closePath() this.ctx.fill() } const currentTime = this.source.mediaElement.currentTime const duration = currentTime / this.source.mediaElement.duration * 100 % 100 document.querySelector('.processbar-side').style.width = duration + '%' this.setLrc(currentTime) } } const music = new Music(document.querySelector('canvas'))
相关资料: https://developer.mozilla.org/zh-CN/docs/Web/API/AudioContext