使用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