DALL·E 3 专辑封面
在本文中,我将分享我如何在周末制作一个项目来发行我的专辑( https://evhaevla.netlify.app/ )。我不是受过训练的音乐家或作曲家,但有时,曲子会在我的脑海中弹出。我把它们记下来,然后让电脑播放它们。
2021年,我推出了专辑《大家都很开心,大家都在笑》。这是一张简单的专辑,出自一位陌生的“作曲家”之手——那就是我。
我不仅仅喜欢音乐;我喜欢音乐。我也是一名开发人员,最近主要关注前端工作。我想,为什么不把这两种爱结合起来呢?因此,我开始设计一个网站来直观地展示我的专辑。
本文不会深入探讨每一个技术细节——那样会太长,而且可能不会吸引所有人。相反,我将重点介绍核心概念和我遇到的障碍。有兴趣的人可以在GitHub上找到所有代码。
我的专辑是为钢琴创作的,因此我的决定很简单。想象一下矩形落在钢琴键上。任何有音乐爱好的人都可能在 YouTube 上遇到过大量以这种方式描绘音符的视频。一个矩形接触到一个琴键,就会照亮它,指示敲击音符的精确时刻。
我不确定这种视觉风格的起源,但快速谷歌搜索主要产生 Synthesia 的屏幕截图。
在 YouTube 上,有些创作者设法制作出令人惊叹的视觉效果。从美学和音乐的角度来看,观看此类视频都是一种享受。看这个或这个。
我们需要实施什么?
让我们解决每一点并将所有这些付诸行动。
最初,我认为实现密钥将构成最大的挑战。然而,快速的在线搜索显示了大量关于如何做到这一点的示例和指南。为了追求优雅的设计,我选择了Philip Zastrow设计的示例。
剩下我要做的就是多次复制琴键并建立一个网格供音符滑过。我使用Vue.js作为前端框架,下面是组件代码。
<template> <ul style="transform: translate3d(0, 0, 0)"> <li :id="`key_${OFFSET - 1}`" style="display: none"></li> <template v-for="key in keys" :key="key.number"> <li :class="`${key.color} ${key.name}`" :id="`key_${key.number}`"></li> </template> </ul> </template> <script setup lang="ts"> import { ref } from 'vue' import type { Key } from '@/components/types' const OFFSET = 24 const template = [ { color: 'white', name: 'c' // Do }, { color: 'black', name: 'cs' // Do-diez }, { color: 'white', name: 'd' // Re }, /* ... */ ] const keys = ref<Key[]>([]) for (let i = 0; i < 72; i++) { keys.value.push({ ...template[i % 12], number: i + OFFSET }) } </script>
我想提一下,我为每个键附加了一个id
属性,这在启动动画时至关重要。
虽然这可能看起来是最简单的部分,但一些挑战却隐藏在显而易见的情况下。
降音符的效果如何实现?
是否需要维护一个可以查询以检索当前笔记的结构?
呈现此类查询结果的最佳方法是什么?
每个问题都构成了顺利实现所需效果的障碍。
我不会在每个问题上徘徊,而是直接切入正题。考虑到与动态方法相关的无数挑战,明智的做法是遵循奥卡姆剃刀原理并选择静态解决方案。
我是这样解决这个问题的:我在一个广阔的画布上同时渲染了所有 6215 个音符。该画布位于一个容器内,该容器的样式带有overflow: hidden
属性。实现下落音符效果只需对该容器的scrollTop
进行动画处理即可。
然而,一个挥之不去的问题仍然存在:如何获取每个音符的坐标?
幸运的是,我有一个 MIDI 文件,其中存储了所有这些音符,这是作为专辑作曲家提供的便利。归根结底,就是利用从 MIDI 文件中提取的数据来渲染音符。
鉴于 MIDI 文件是二进制格式,并且我无意自己解析它,因此我寻求了midi 文件库的帮助。
midi-file
库可以高效地从 MIDI 文件中提取原始数据,但对于我的需求来说,这还不够。我的目标是将这些数据转换为更易于访问和应用程序友好的格式,以促进应用程序内的无缝渲染。
在 MIDI 文件中,我处理的不是通常意义上的音符,而是事件。这些事件有一整套,但我主要关注两种类型:“noteOn”(按下按键时触发)和“noteOff”(释放按键时触发)。
“noteOn”和“noteOff”事件分别指定按下或释放的特定音符编号。 MIDI 中不存在传统意义上的时间。相反,我们有“蜱虫”。每个节拍的刻度数在 MIDI 文件的标题中有详细说明。
确实,还有更多需要考虑的事情。考虑到节奏在播放过程中可能会发生变化,还存在节奏轨道,其中包含流程中不可或缺的“setTempo”事件。我最初的方法涉及调整容器的scrollTop
属性的动画速度以与节奏保持一致。
然而,我很快意识到,由于错误累积过多,这不会产生预期的结果。事实证明,“线性”时间拉伸对于scrollTop
动画更有效。
即使动画方面已经排序,节奏仍然需要合并。我通过调整音符矩形本身的长度来解决这个问题。虽然不是最佳解决方案(操纵速度是理想的),但这种方法确保了更平稳的操作。
这个解决方案并不完美,主要是因为我根据速度事件与音符事件是否具有相同或更少的时间来将它们关联起来。这意味着,如果在音符仍处于播放状态时发生另一个速度事件,它将被忽略。
这可能会导致错误,特别是如果音符很长并且在演奏期间发生剧烈的速度变化。这是一个权衡。我已经接受了这个小缺陷,因为我专注于快速开发。
在某些情况下,速度优先,更务实的做法是不要纠缠于每个细节。
因此,我们配备了以下信息:
有了这些详细信息,我就可以在画布上精确定位每个音符的坐标。键号决定 X 轴,而按键的开始时间是 Y 轴。压力机的长度决定了矩形的高度。
通过使用标准 div 元素并将其位置设置为“绝对”,我成功地达到了预期的效果。
我并不打算为钢琴创建一个合成器,因为那会占用很多时间。相反,我使用了已经“渲染”的现有 OGG 文件,并选择 Native Instruments 的The Grandeur作为声音库。
就我个人而言,我相信它是目前最好的钢琴 VST 乐器。
我将生成的 OGG 文件嵌入到标准音频元素中。我的主要任务是将音频与我的笔记画布的scrollTop
动画同步。
在我能够解决同步问题之前,必须首先建立动画。画布动画相当简单——我使用线性插值将scrollTop
从无限值动画化到零。该动画的持续时间与专辑的长度相匹配。
当音符落在某个琴键上时,该琴键就会亮起。这意味着对于每个音符的下降,我需要“激活”相应的键,并且一旦音符完成其过程,将其停用。
总共有 6215 个音符,这相当于多达 12,430 个音符激活和停用动画。
此外,我的目标是为用户提供倒带音频的功能,使他们能够导航到专辑中的任何位置。要实现这样的功能,强大的解决方案至关重要。
当需要“正常工作”的可靠解决方案时,我的首选始终是GreenSock 动画平台。
查看为每个键创建所有动画所需的代码量。使用id
为组件添加动画并不是单页应用程序的最佳实践。然而,这种方法确实可以节省时间。还记得我提到的每个键的id
吗?这就是他们发挥作用的地方。
const keysTl = gsap.timeline() notes.value.forEach((note) => { const keySelector = `#note_${note.noteNumber}` keysTl .set(keySelector, KEY_ACTIVE_STATE, note.positionSeconds) .set(keySelector, KEY_INACTIVE_STATE, note.positionSeconds + note.durationSeconds - 0.02) })
同步代码本质上是通过音频和 GSAP 全局时间线之间的事件建立连接。
audioRef.value?.addEventListener('timeupdate', () => { const time = audioRef.value?.currentTime ?? 0 globalTl.time(time) }) audioRef.value?.addEventListener('play', () => { globalTl.play() }) audioRef.value?.addEventListener('playing', () => { globalTl.play() }) audioRef.value?.addEventListener('waiting', () => { globalTl.pause() }) audioRef.value?.addEventListener('pause', () => { globalTl.pause() })
正当我想要结束的时候,一个有趣的想法突然出现在我的脑海中。如果我为专辑添加独特的元素会怎样?它最初并不在我的待办事项清单上,但我觉得如果没有这个功能,该项目就不会真正发光发热。因此,我也选择将其纳入其中。
每当我沉浸在一首歌曲中时,我都会发现自己反思其更深层次的含义。作曲家想传达什么信息?例如,考虑一下 Ludovico Einaudi 的《Nightbook》中的一个片段。钢琴在左耳中共鸣,而弦乐在右耳中回响。
它营造了两人之间展开对话的氛围。感觉就像钢琴键在试探:“你同意吗?”琴弦做出肯定的回应。 “这就是询问吗?”琴弦回应了他们的肯定。该序列以两种乐器的融合达到高潮,象征着团结与和谐的实现。这难道不是一种令人着迷的体验吗?
必须指出的是,这纯粹是我个人的解释。有一次,我有机会去米兰参加Ludovico 的音乐会。表演结束后,我走近他,询问他是否确实打算在该特定片段中嵌入对话的概念。
他的回答很有启发性:“我从来没有这样想过,但你确实拥有生动的想象力。”
借鉴这次经验,我思考:如果我将字幕集成到乐谱中会怎样?当特定片段播放时,评论可能会在屏幕上出现,为作曲家的意图提供见解或解释。
这一功能可以让听众对“作者真正的意思是什么?”有更深入的理解或新的视角。
幸运的是我选择了 GSAP 作为我的动画工具。它使我能够毫不费力地整合另一个时间线,专门负责动画评论。这一添加简化了流程,使我的想法的实施更加顺利。
我倾向于通过 HTML 标记来引入注释。为了实现这一目标,我制作了一个在onMounted
事件期间引入动画的组件。
<template> <div :class="$style.comment" ref="commentRef"> <slot></slot> </div> </template> <script setup lang="ts"> /* ... */ onMounted(() => { if (!commentRef.value) return props.timeline .fromTo( commentRef.value, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.5 }, props.time ? parseTime(props.time) : props.delay ? `+=${props.delay}` : '+=1' ) .to( commentRef.value, { autoAlpha: 0, duration: 0.5 }, props.time ? parseTime(props.time) + props.duration : `+=${props.duration}` ) }) </script>
该组件的使用如下。
<template> <div> <Comment time="0:01" :duration="5" :timeline="commentsTl"> <h1>A title for a track</h1> </Comment> <Comment :delay="1" :duration="13" :timeline="commentsTl"> I would like to say... </Comment>
所有元素就位后,下一步就是托管该网站。我选择了 Netlify。现在,我邀请您体验这张专辑并观看最终的演示。
我真诚地希望还有其他热爱钢琴的开发者,渴望以如此独特的方式展示他们的专辑。如果您是其中之一,请毫不犹豫地分叉该项目。