写点什么

[鸿蒙征文] 钢琴和弦小工具

作者:大展红图
[鸿蒙征文]钢琴和弦小工具

前言

人,不但要有科学技术,而且还要,文化,艺术,跟音乐。————钱学森


我特别认同钱老说的这句话。我们作为理工科为主的开发者,不能忘记陶冶自己的情操。所以业余时间,我也会弹一点钢琴。有一天,我的需求就来了。那天的乐谱和平常不一样,左手的音符不是画在五线谱上的,是以和弦形式标注的。这一下把我这个野路子难住了。我可没法记住那些和弦的规律。那么我能不能自己开发一个小工具,帮我来查询钢琴和弦按键的方法呢?

一点点乐理

我们先快速过一下乐理。首先钢琴键盘上由一个个八度组成。从 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 = 7private 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。这样,任意和弦的数据在之后添加的时候就非常简单了。

结尾

这个应用看似功能简单,实则在实现的时候,有很多细节需要考虑。主要难点在于布局的序号和实际可见按键序号的分离。布局的序号有利于布局循环,可见按键的序号用于和弦数据的表达。


我认为人类的知识应该是融会贯通的,而不是各自成为孤岛。科学和艺术应该是相互融合的,比如黄金分割数会很自然的给人带来美感。


没有艺术的科学是没有灵魂的,没有科学的艺术是杂乱的。


感谢阅读。

发布于: 刚刚阅读数: 9
用户头像

大展红图

关注

还未添加个人签名 2018-05-11 加入

还未添加个人简介

评论

发布
暂无评论
钢琴和弦小工具_鸿蒙_大展红图_InfoQ写作社区