前言
人,不但要有科学技术,而且还要,文化,艺术,跟音乐。————钱学森
我特别认同钱老说的这句话。我们作为理工科为主的开发者,不能忘记陶冶自己的情操。所以业余时间,我也会弹一点钢琴。有一天,我的需求就来了。那天的乐谱和平常不一样,左手的音符不是画在五线谱上的,是以和弦形式标注的。这一下把我这个野路子难住了。我可没法记住那些和弦的规律。那么我能不能自己开发一个小工具,帮我来查询钢琴和弦按键的方法呢?
一点点乐理
我们先快速过一下乐理。首先钢琴键盘上由一个个八度组成。从 do 到 si 循环。每个循环一共包含 7 个白键,5 个黑键。图中展示了钢琴键盘的一部分。里面有两个八度。第一个八度的 7 个白键从 do 到 si 被标注出来。
也许有人会好奇,为什么 mi fa 和 si do 之间没有黑键?这个我没有去具体考证,但布局中的这个特殊情况,会对我们理解音高和之后的代码编写有这至关重要的影响。
所以接下来就要说说音高。在钢琴键盘上,白键的 do re 之间,re mi 之间,各相差一个音高。于是,do re 之间的黑键,和 do re,各相差半个音高。这个黑键在琴谱上,可以表示为升 do 或者降 re,指的是同一个键。
那么同样是白键,到了 mi fa,是不是也相差一个音高呢?答案是否定的。mi fa 相差的是半个音高。
所以音高的规律实际上是:键盘上相邻的两个按键,相差半个音高(注意不是相邻的两个白键)。可以把键盘的下半部分遮住,只看上半部分。忽略相邻的按键的颜色,相邻两个按键之间的音高就是相差半个。
接下来我们讲讲和弦,以最常用的大三和弦举例。网上很多解释和弦的文章,都会非常教条式的讲音程,大三度,小三度。这个会取决于按键之间有没有间隔黑键。实际上这个说法理解起来太复杂了,也不利于记忆。按上文对音高的解释,我直接理解成,几个按键之间相邻多少个半音。这样无论根音是哪个,对应的其他几个按键都根据多少个半音去找就行了。
这样我们就抓住了和弦的本质,对于之后的代码有指导性思想。
功能简介
整个应用的功能不多,点击某个按键作为根音,根音以粉色表示,和弦中,其他几个按键以黄色表示。左上角的按钮可以呼出和弦类型选择列表的弹窗,选择需要的和弦类型,键盘上会有相应的变化。
整个应用的实现,关键在于键盘的绘制。另外就是一个和弦的选择列表的弹窗。这个弹窗不细讲,因为包括官方文档在内的社区里很多入门帖子都会讲到类似的案例。
布局思路
我们选择用两个循环,分别布局白键的 row 和黑键的 row
键盘组件类
我把键盘布局作为一个 component,声明了这些成员。
@Prop currentChord: number[] // 当前的和弦类型
@State private currentKey: number = -1 // 当前的按键(根音)
// 白键黑键的尺寸
private WHITE_KEY_WIDTH = 7
private WHITE_KEY_HEIGHT = 100;
private BLACK_KEY_WIDTH = 3.5;
private BLACK_KEY_HEIGHT = 63;
private SELECTED_KEY_COLOR = "#FFC0CB" // 已选择的按键颜色
private CHORD_KEY_COLOR = "#FFFF00" // 和弦中其他按键的颜色
复制代码
这里是一些用于判断的方法
private isSelected(index: number): boolean { // 判断当前按键是否被选中(是否为根音)
return this.currentKey === index
}
private isChord(index: number): boolean { // 当前是否为和弦中的按键
if (this.currentKey === -1) {
return false
}
const currentKey_ = this.currentKey % 12
return this.currentChord.includes(index - currentKey_)
}
private isFakeBlack(index: number): boolean { // 是否为透明的黑键
return (index + 1) % 14 === 6 || (index + 1) % 14 === 0
}
复制代码
这里的 isFakeBlack()方法需要特别说明一下。因为黑键的循环布局比较特殊。如果 mi fa 和 si do 之间也有黑键,那布局起来就非常有规律了。白键用一个循环,黑键用一个循环,每个按键之间间隔相同的距离。但事实却是,mi fa 和 si do 之间没有黑键。所以,这里在布局的时候实际上还是按照有黑键的情形来布局的,再把 mi fa 和 si do 之间的黑键做成透明的。所以这两个透明的按键我称为 fake black。
但这会带来一个问题,就是如果我点击的范围,正好落在 fake black 布局中,就会响应到一个透明按钮上。但实际上我想要的是下方的白色按键触发响应。这种情况需要设置控件的 hitTestBehavior 属性。
如果是正常显示的黑键,设置为 HitTestMode.Default。
如果是 fake black,需要响应下方的白键的正常触摸,所以要把这个 fake black 按键的触摸做穿透。参考官方文档,HitTestMode.None 行为是:自身不响应触摸测试,不会阻塞子节点和兄弟节点的触摸测试。
所以 fake black 布局范围内不响应触摸了,当点击 fake black 范围内时,他的父布局 row 会响应触摸。但这样还是没法让布局下方的白色按键响应到。所以对他的父布局 row 也要设置 hitTestBehavior 为 HitTestMode.None。
这样就能在点击 fake black 布局范围内时,让下方的白色按键响应到触摸事件。
绘制键盘
根据之前设计的布局思路,根布局为 stack,白键的 row 布局在下,黑键的 row 布局在上。两个 row 布局里面各有一个循环,把 subKeys 遍历一遍。之后解释 subKeys 的实现,现在可以简单的理解为每个按键的遍历。更多的细节请看代码中的注释。
build() {
Stack() {
Row() {
ForEach(this.subKeys(true), (item: Key, i) => { // 白键的遍历
Button()
.width(this.WHITE_KEY_WIDTH + "%") // 设置按键的宽
.height(this.WHITE_KEY_HEIGHT + "%") // 设置按键的高
.backgroundColor( // 设置按键颜色
item.isSelected ? this.SELECTED_KEY_COLOR : // 如果是选中的按键,用SELECTED_KEY_COLOR
(item.isChord ? this.CHORD_KEY_COLOR : "white") // 其他的按键不是选中的按键,会有两种情况。如果是和弦中的键,用CHORD_KEY_COLOR,如果不是,那就是普通的白色按键,用白色填充。
)
.borderWidth(1)
.borderColor("black")
.type(ButtonType.Normal)
.stateEffect(false)
.onClick(() => {
this.currentKey = item.id // 点击之后,把currentKey成员赋值为当前按键的id
console.log("click white")
})
})
}
.width("100%")
.justifyContent(FlexAlign.Center)
Row() {
ForEach(this.subKeys(false), (item: Key, i) => { // 黑键的遍历
Button()
.width(this.BLACK_KEY_WIDTH + "%")
.height(this.BLACK_KEY_HEIGHT + "%")
.backgroundColor( // 这里设置背景颜色比较复杂,因为涉及倒fake black的判断
item.keyType === KeyType.fakeBlack ? Color.Transparent : // 首先判断是否为fake black,如果是,就显示透明色。
(item.isSelected ? this.SELECTED_KEY_COLOR : // 如果不是,就类似白键的判断。
(item.isChord ? this.CHORD_KEY_COLOR : "black")) // 非选中或者和弦按键就填充为黑色。
)
.type(ButtonType.Normal)
.stateEffect(false)
.onClick(() => {
this.currentKey = item.id
console.log("click black")
})
.hitTestBehavior(item.keyType === KeyType.black ? HitTestMode.Default : HitTestMode.None) // 见下文解释
})
}
.width("94.5%")
.justifyContent(FlexAlign.SpaceEvenly)
.hitTestBehavior(HitTestMode.None) // 见下文解释
}
.alignContent(Alignment.Top)
}
复制代码
export enum KeyType {
white,
black,
fakeBlack
}
复制代码
遗留的 subKeys()方法
我们继续来看下之前遗留的 subKeys()方法。这个方法在前文中,用于给黑键和白键分别布局。
private subKeys(isWhite: boolean): Key[] { // 根据入参的布尔值,返回黑键或者白键的对象数组subKeys。Key类声明见下文。
const subKeys: Array<Key> = new Array<Key>()
let id = 0 // 这个id很关键,用于UI布局里面,所有的按键按照半音间隔依次排列,这个id就是可见按键的序号。
for (let i = 0; i < 27; i++) { // 两个八度的按键循环,算上fake black,一共28个按键。
if (isWhite) { // 如果需要返回白键
if (i % 2 === 0 ) { // 第一个按键是白键,所以判断循环计数为偶数。
subKeys.push(new Key(id, KeyType.white, this.isSelected(id), this.isChord(id)))
}
} else { // 如果需要返回黑键
if (i % 2 === 1 ) { // 根据半音间隔排列,第二个按键是黑键,所以判断循环计数为奇数。
let keyType = KeyType.black
if (this.isFakeBlack(i)) { // mi fa si do 中间的空挡(fake black)。此处keyType需要做特殊处理。
keyType = KeyType.fakeBlack
}
subKeys.push(new Key(id, keyType, this.isSelected(id), this.isChord(id)))
}
}
if (!this.isFakeBlack(i)) { // 每次循环最后要判断一次当前是否遍历到的是fake black,如果不是fake black,即为实际显示的按钮,那么id计数加一。如此,返回的可见黑键id即为正常累加。
id++
}
}
return subKeys
}
复制代码
export class Key {
public id: number
public keyType: KeyType // 白键,黑键还是fake black
public isSelected: boolean // 是否被选中
public isChord: boolean // 是否为和弦按键
constructor(
id: number,
keyType: KeyType,
isSelected: boolean,
isChord: boolean
) {
this.id = id
this.keyType = keyType
this.isSelected = isSelected
this.isChord = isChord
}
}
复制代码
明白了 subKeys()实现之后,可以回过头再理解一下前文中的 isFakeBlack()方法
private isFakeBlack(index: number): boolean {
return (index + 1) % 14 === 6 || (index + 1) % 14 === 0
}
复制代码
subKeys()的循环是遍历所有包括白色和黑色的按键,所以我们在图里按顺序给所有按键编号,便于理解。我们把 index 加一再判断,符合日常的编号习惯。而且这样正好把一个八度里面所有显示和不显示的按键都编上了号,一共 14 个,下一个八度正好重新循环。可以看到把 index+1 模除 6 和 14 的时候,这种情况是需要隐藏的按键。
显示和弦
万事俱备,只欠东风。至此,其实代码在之前都已经展示过了。但还需要梳理一下,显示和弦的流程。
首先在成员属性里面向 currentChord 赋值。
@Prop currentChord: number[]
复制代码
这个数字的数组含义为,和弦中所有按键之间的半音间隔数量。在之前布局的时候,subKeys()方法的核心思想是把所有按键按照间隔顺序编号。所以我们以大三和弦举例,根音为 do 时,需要的按键序号 id(这里的序号和上文中包含 fake black 按键的序号需要注意区分)就是 0,4,7。但这个数组不能表达按键的序号,表达的应该是按键之间的半音间隔数量。因为根音还会有 re 或者 mi 和其他任意按键。
这是之前提到的 isChord()方法,用于判断某个按键是否为和弦。每次点击按键以后,会给 this.currentKey 赋值当前按键的 id,因为按下的按钮需要给粉色,所以遍历到当前按钮时也会返回 false。只有根音以外的其他按键才会返回 true。
private isChord(index: number): boolean { // index为布局时遍历到的每一个按键
if (this.currentKey === -1) {
return false
}
const currentKey_ = this.currentKey % 12 // 模除12,只在第一个八度中显示和弦按键
return this.currentChord.includes(index - currentKey_) // 把遍历到的按键序号index和当前按下的按键序号currentKey_相减,得到按键之间的半音间隔,如果这个间隔包含在数组中,即为和弦按键。
}
复制代码
理解了和弦数据表达以后,我们得出大三和弦的数组即为 0,4,7。以此类推,小三和弦为 0,3,7。七和弦为 0,4,7,10。这样,任意和弦的数据在之后添加的时候就非常简单了。
结尾
这个应用看似功能简单,实则在实现的时候,有很多细节需要考虑。主要难点在于布局的序号和实际可见按键序号的分离。布局的序号有利于布局循环,可见按键的序号用于和弦数据的表达。
我认为人类的知识应该是融会贯通的,而不是各自成为孤岛。科学和艺术应该是相互融合的,比如黄金分割数会很自然的给人带来美感。
没有艺术的科学是没有灵魂的,没有科学的艺术是杂乱的。
感谢阅读。
评论