多点触控支持,用于十指弹奏





5.00/5 (1投票)
微调织物使用多点触控屏幕支持音乐键盘。
目录
引言
问题:键盘按键不像按钮!
使用多点触控控制的 Microtonal Fabric 应用
实现
多点触控
使用示例
抽象键盘
使用额外数据
兼容性
结论
引言
这是本系列第四篇文章,专门介绍使用屏幕键盘(包括微音程键盘)进行音乐学习。
前三篇文章致力于一个名为 Microtonal Fabric 的项目,这是一个基于 WebAudio API 的微音程音乐平台。它是一个用于构建通用或定制的微音程音乐键盘乐器、微音程实验和计算、音乐学习以及教授音乐课程(可能包含远程选项)的框架。该平台提供多个在 Web 浏览器中运行的应用程序,使用共享的 JavaScript 组件。
另请参阅我在微音程社区网站 Xenharmonic Wiki 上的 页面。除了 Microtonal Fabric 的链接外,还有一些关于不同微音程主题和人物的有用链接。
大多数 Microtonal Fabric 应用程序都允许用户在浏览器中演奏音乐。如果有多点触控屏幕可用,用户可以进行十指弹奏。多点触控界面的必需功能并不像乍看起来那么简单。本文解释了这个问题及其解决方案。它展示了如何将多点触控行为从代码的其他部分抽象出来,并由不同类型的屏幕键盘重用和利用。
在所讨论的方法中,本多点触控解决方案的应用不限于音乐键盘。键盘的*视图模型*看起来像是一个 HTML 或 SVG 元素集合,有两个状态:“激活”(按下)或“未激活”(释放)。状态可以通过用户或软件以多种不同方式修改。首先,让我们考虑整个问题。
问题:键盘按键不像按钮!
那么,为什么键盘的多点触控控制会带来一些问题呢?嗯,主要是因为思维惯性可能会将我们引向错误的方向。
最常规的方法是取一组键盘的按键,并为每个按键附加一个事件处理程序。这看起来很自然,但完全行不通。
让我们看看用十指弹奏需要什么。在屏幕上,我们有三种区域:1)某个键盘按键的区域,2)键盘上未被按键占用的区域,3)键盘外部的区域。当一个或多个手指触摸屏幕内某个按键的区域(情况 #1)时,会创建一个 Touch 对象。会调用低级触摸事件,但只有在给定按键区域没有其他Touch
对象时,才应调用语义级别的键盘事件来处理按键激活。同样,当手指从屏幕上移开时,只有当给定按键区域没有其他Touch
对象时,才应调用语义级别的键盘事件。
但这还不够。当手指在屏幕上滑动时,按键的激活状态也可以改变。当一个滑动的指头进入或离开某个按键的区域时,它可以来自或移动到 #1 到 #3 类型的任何区域。根据给定按键区域中其他Touch
对象的存在情况,它也可以改变该按键的激活状态。最典型的情况是手指跨越两个或多个按键滑动。这种技术被称为*滑音*。而且,当触摸事件单独附加到每个按键时,这是无法实现的。
为什么?通过与指针事件进行比较,很容易理解。这些事件包括pointerleave
和pointerout
事件。这些事件对于由鼠标或触摸板控制的单个指针非常有用。然而,触摸事件中并没有类似的东西。键盘按键根本不像 UI 按钮那样工作。尽管表面上相似,但它们完全不同。
实现语义级别多点触控事件所有组合的唯一方法是处理包含所有按键的某个元素的低级触摸事件。在 Microsoft Fabric 代码中,这是代表整个键盘的元素。让我们看看它在实现 多点触控部分是如何实现的。
在查看实现之前,读者可能想先了解一下可用的使用多点触控控制的 Microtonal Fabric 应用程序。对于所有应用程序,都可以进行*实时演奏*。对于每个应用程序,实时演奏的 URL 如下所示。
使用多点触控控制的 Microtonal Fabric 应用
Application | 源代码 | 实时演奏 |
---|---|---|
多 EDO | ./Multi-EDO | 多 EDO |
29-EDO | ./29-EDO | 29-EDO |
微音程游乐场 | ./playground | Aura 的自然音阶 常用 12-EDO Shruti 音阶 中国传统音调系统 微音程游乐场自定义演示 |
Kite Giedraitis 键盘 (开发中) | ./Kite.Giedraitis | Kite Giedraitis |
实现
思路是:我们需要一个独立的单元,从代表键盘的 UI 元素集合中抽象出来。我们将把一些触摸事件附加到代表整个键盘的单个 HTML 或 SVG 控件上。这些事件应该由可能与键盘按键相关或不相关的某些事件来解释。为了从用户那里提取有关按键的信息,我们将使用*控制反转*。
触摸功能是通过调用setMultiTouch
函数一次性将事件附加到代表整个键盘的某个元素来实现的,该函数获取按键配置信息并通过三个回调处理程序调用语义级别的按键事件。让我们看看它是如何工作的。
多点触控
函数setMultiTouch
假设多点触控敏感区域具有以下 UI 模型:一个container
HTML 或 SVG 元素,其中包含一个或多个 HTML 或 SVG 子元素,它们可以是直接或间接的子元素。
该函数接受四个输入参数:container
和三个处理程序
container
:处理多点触控事件的 HTML 或 SVG。elementSelector
:用于在container
中选择相关子元素的处理程序。
配置文件:element => bool
如果此处理程序返回false
,则忽略该事件。本质上,此处理程序由用户代码使用,用于定义将被解释为由container
代表的某个键盘按键的 HTML 或 SVG 元素。elementHandler
:用于实现主要键盘功能的处理程序。
配置文件:(element, Touch touchObject, bool on, touchEvent event) => undefined
此处理程序用于实现主要功能,例如,响应键盘事件产生声音;该处理程序接受element
、一个触摸对象和一个布尔值on
参数,指示这是“开启”还是“关闭”操作。基本上,此处理程序调用一个通用的语义处理程序,该处理程序可以通过不同方式触发,例如,通过键盘或鼠标。本质上,它实现了当由element
表示的键盘按键被激活或停用时触发的动作,具体取决于on
的值。sameElementHandler
:用于处理同一元素内事件的处理程序。
配置文件:(element, Touch touchObject) => undefined
“ui.components/multitouch.js”
"use strict";
const setMultiTouch = (
container,
elementSelector,
elementHandler,
sameElementHandler,
) => {
if (!elementSelector)
return;
if (!container) container = document;
const assignEvent = (element, name, handler) => {
element.addEventListener(name, handler,
{ passive: false, capture: true });
};
const assignTouchStart = (element, handler) => {
assignEvent(element, "touchstart", handler);
};
const assignTouchMove = (element, handler) => {
assignEvent(element, "touchmove", handler);
};
const assignTouchEnd = (element, handler) => {
assignEvent(element, "touchend", handler);
};
const isGoodElement = element => element && elementSelector(element);
const elementDictionary = {};
const addRemoveElement = (touch, element, doAdd, event) => {
if (isGoodElement(element) && elementHandler)
elementHandler(element, touch, doAdd, event);
if (doAdd)
elementDictionary[touch.identifier] = element;
else
delete elementDictionary[touch.identifier];
}; //addRemoveElement
assignTouchStart(container, ev => {
ev.preventDefault();
if (ev.changedTouches.length < 1) return;
const touch = ev.changedTouches[ev.changedTouches.length - 1];
const element =
document.elementFromPoint(touch.clientX, touch.clientY);
addRemoveElement(touch, element, true, ev);
}); //assignTouchStart
assignTouchMove(container, ev => {
ev.preventDefault();
for (let touch of ev.touches) {
let element =
document.elementFromPoint(touch.clientX, touch.clientY);
const goodElement = isGoodElement(element);
const touchElement = elementDictionary[touch.identifier];
if (goodElement && touchElement) {
if (element == touchElement) {
if (sameElementHandler)
sameElementHandler(element, touch, ev)
continue;
} //if same
addRemoveElement(touch, touchElement, false, ev);
addRemoveElement(touch, element, truem, ev);
} else {
if (goodElement)
addRemoveElement(touch, element, goodElement, ev);
else
addRemoveElement(touch, touchElement, goodElement, ev);
} //if
} //loop
}); //assignTouchMove
assignTouchEnd(container, ev => {
ev.preventDefault();
for (let touch of ev.changedTouches) {
const element =
document.elementFromPoint(touch.clientX, touch.clientY);
addRemoveElement(touch, element, false, ev);
} //loop
}); //assignTouchEnd
};
setMultiTouch
实现的核心是调用 document.elementFromPoint。这样,就可以找到与 Touch 事件数据相关的元素。找到元素后,会检查它是否是代表键盘按键的元素,并且isGoodElement
函数使用处理程序elementSelector
来执行此操作。如果是,则根据事件数据调用elementHandler
或sameElementHandler
处理程序。这些调用用于处理 触摸事件 "touchstart"
、"touchmove"
和"touchend"
。
让我们看看应用程序如何使用setMultiTouch
。
使用示例
在 29-EDO 应用程序中可以找到一个非常典型的使用示例。它提供了几个键盘布局和两个不同的音调系统(29-EDO 和常用的 12-EDO),但键盘重用了大量公共代码。对于所有键盘布局,elementSelector
都基于这样一个事实:所有键盘按键都是矩形 SVG 元素 SVGRectElement,但键盘本身并不是,它们由一个 SVG 元素 SVGSVGElement 代表。
此外,键盘具有一个通用的语义级别处理程序handler(element, on)
,它根据按键element
的布尔激活状态on
来控制其高亮显示和音频动作。这是一个通过触摸 API 和 指针 API 使用的通用处理程序。该处理程序也可以通过计算机键盘或其他控件通过代码激活。特别是,它可以被 Microtonal Fabric 序列*录音机*调用。这使得对setMultiTouch
的调用非常简单。
“29-EDO/ui/keyboard.js”
setMultiTouch(
element,
element => element.constructor == SVGRectElement,
(element, _, on) => handler(element, on));
在这里,第一个element
代表一个键盘,一个 SVGRectElement,而处理程序的element
参数代表键盘按键。
在另一个地方,使用了表达式element.constructor == SVGCircleElement
,因为该应用程序只有圆形按键。
有一个更专门的示例,其中按键元素的选择是通过某个抽象 JavaScript 类的*方法*执行的。
“ui.components/abstract-keyboard.js”
class AbstractKeyboard {
//...
setMultiTouch(
parentElement,
keyElement => this.isTouchKey(parentElement, keyElement),
(keyElement, _, on) => handler(keyElement, on));
}
第二个示例从编程角度来看更有趣。让我们更详细地讨论它。
抽象键盘
最后一个示例展示了一个额外的抽象层:用于设置多点触控功能的通用代码一次性放置在一个代表抽象键盘的类中。潜在地,不止一个终端键盘类可以从AbstractKeyboard
派生,并重用多点触控设置和其他通用的键盘功能。
在AbstractKeyboard
类中,调用setMultiTouch
时使用的函数并未完全定义:函数this.isTouchKey
根本没有定义,并且函数handler
已定义,但它依赖于尚未定义的函数。这些函数应该在所有从AbstractKeyboard
派生的终端类中实现。但如何保证这一点?
为了保证这一点,我提出了一种我称之为*接口*(“agnostic/interfaces.js”)的新技术。键盘类不继承适当的接口类,它们只是实现定义在特定接口中的函数,该接口是IInterface
类(agnostic/interfaces.js”)的子类。IInterface
的唯一目的是提供一种通过*严格性*程度来早期检测问题的*方法,该问题是由于未完全实现接口造成的。要理解严格性的概念,请参阅同一文件中的const IInterfaceStrictness
,它是不言自明的。
在 Microtonal Playground 应用程序的示例中,终端键盘类不是直接通过调用setMultiTouch
来实现多点触控行为,而是通过以下继承图继承自AbstractKeyboard
:
AbstractKeyboard
(“ui.components\abstract-keyboard.js”) ◁─ GridKeyboard
(“ui.components\grid-keyboard.js”) ◁― PlaygroungKeyboard
(“playground\ui\playground-keyboard.js”)
除了继承自AbstractKeyboard
之外,终端类还应该实现IKeyboardGeometry
。
IInterface
(“agnostic/interfaces.js”) ◁— IKeyboardGeonetry
(“ui.components\abstract-keyboard.js”)
“ui.components\abstract-keyboard.js”
class IKeyboardGeometry extends IInterface {
createKeys(parentElement) {}
createCustomKeyData(keyElement, index) {}
highlightKey(keyElement, keyboardMode) {}
isTouchKey(parentElement, keyElement) {} // for touch interface
get defaultChord() {} // should return array of indices of keys in default chord
customKeyHandler(keyElement, keyData, on) {} // return false to stop embedded handling
}
此接口的实现保证了AbstractKeyboard
函数能够正常工作,并且接口实现已完全满足的事实由AbstractKeyboard
类的构造函数进行验证。
此验证在接口实现不令人满意的情况下会抛出异常。
class AbstractKeyboard {
#implementation = { mode: 0, chord: new Set(), playingElements: new(Set), chordRoot: -1, useHighlight: true };
derivedImplementation = {};
derivedClassConstructorArguments = [undefined];
constructor(parentElement, ...moreArguments) {
IKeyboardGeometry.throwIfNotImplemented(this);
this.derivedClassConstructorArguments = moreArguments;
//...
}
//...
}
有关IInterface.throwIfNotImplemented
的详细信息,请参阅源代码,“agnostic/interfaces.js”。接口实现的验证基于构造函数在运行时对终端类执行的反射。它会检查所有接口函数、属性 getter 和 setter,并根据所需的严格性检查每个函数的参数数量。在我们的例子中,这发生在 Microtonal Playground 应用程序的终端应用程序级类PlaygroungKeyboard
的构造过程中。该应用程序本身值得单独介绍。
显然,这只是对某些设计良好的编译语言中找到的接口机制的一种模仿。它在运行时稍晚(但尽早)验证接口实现。当然,该机制不能改变软件的行为,它只会方便调试,从而促进软件开发。该机制确实有效,但我认为它是实验性的,并且理解其价值是可讨论的。我将非常感谢任何批评或建议。
使用额外数据
请注意,没有一个示例使用了处理程序handler
的第二个参数,该参数被接受为setMultiTouch
的第二个参数,即 Touch 类型的touchObject
。并且处理程序的最后一个参数event
,类型为 TouchEvent,未使用。同样,setMultiTouch
的最后一个参数,处理程序sameElementHandler
,也未使用。但是,这些参数功能齐全,并且可以使用。它们保留用于高级用途。
传递给handler
的Touch
参数用于获取原始触摸事件的额外信息。特别是,我尝试使用 Touch.radiusX 和 Touch.radiusY 的值。我的想法是评估手指与触摸屏的接触面积。这些信息可用于推导压力大小,从而根据该值调整音量,为演奏增加一些动态。然而,我的实验表明,演奏者很难控制这个值,它与实际压力不相同。那些 Touch 成员属性的一个更根本的问题是,它们的改变不会触发任何触摸事件;只有当触摸的质心改变时才会触发事件。尽管如此,显然Touch
数据对于实现一些高级效果非常有用。
处理程序的event
参数用于实现拨弦乐器。该参数的主要目的是找出在多个手指按下同一琴弦并压住指板的情况下,哪个手指定义了声音频率。用于模仿拨弦乐器的乐器应用程序目前正在开发中。
函数setMultiTouch
的参数sameElementHandler
在触摸事件被触发时被调用,此时触摸的位置与前一个触摸事件保持在同一个element
内。显然,此类事件不应修改element
的激活状态。同时,此类事件可用于实现更精细的技术。例如,手指在同一个按键内的移动可以被解释为手指控制的*颤音*。
上面提到的所有更精细的技术都是进一步研究的课题。
兼容性
Microtonal Fabric 的功能基于高级和现代的 JavaScript 和浏览器功能,在使用某些 Web 浏览器时可能会失败。基本上,它在所有 Chromium 内核浏览器上都能正常工作,或者更确切地说,是基于 Blink 和 V8 引擎的现代浏览器。这意味着 Chromium、Chrome 和一些其他派生浏览器。甚至最新的“Anaheim” Microsoft Edge 也能正常工作。
不幸的是,当今的 Mozilla 浏览器出现了一些问题,无法正确运行 Microtonal Fabric 应用程序。特别是,Linux 版的 Firefox 无法正确处理本文描述的 触摸事件。Microtonal Fabric 用户和我正在关注兼容性,因此如果情况发生变化,我会尝试更新兼容性信息。
结论
我们有一个机制和一个抽象层,可以用作更通用的触摸 API 的语义包装器,该 API 更接近触摸屏硬件。该层以适合屏幕键盘或其他功能相似的 UI 元素的*视图模型*的形式呈现事件。使用此层的代码会调用单个函数setMultiTouch
并传递其语义处理程序。
可选地,在此层之上,用户可以使用基于IKeyboardGeometry
和 JavaScript 类的更专用且可能通用性较低的 API。
这两个选项提供了一个全面的语义级别机制,用于实现基于多点触控触摸屏的屏幕键盘的所有行为方面。同时,它也允许 UI 接受其他输入方法,例如物理计算机键盘、鼠标或触摸板。