目录
写在前面
本项目的灵感来源
作品完成度
项目介绍
APP的名字:沐尘调音器
APP功能介绍
调音器
节拍器
实际测试
调音器测试
节拍器测试
项目内容
开发环境
APP功能原理
1.调音器-编程原理
2.节拍器-编程原理
代码框架
1.代码文件的分类
2.Manifest配置文件
3.启动界面和主界面
4.调音器界面
5.节拍器界面
项目总结
遇到的问题
总结
写在前面
本项目的灵感来源
本人二胡练习时长11年半,本科也在校民乐团待了4年。众所周知,学习弦乐器、管乐器、部分打击乐器,包括二胡、古筝、琵琶、中阮、提琴、钢琴、吉他、竖琴、竹笛、马林巴等,必然会接触到调音器(用来校准乐器的音准)。另外,所有学习音乐的人群都会接触到节拍器(用来帮助练习节奏准确度)。
然而,当我发现自己经常用的这种APP有广告而且开始收费时,我就想,能不能自己做一个这种类型的APP呢,正好自己也经常能用的到?作为一个在学校系统学过C语言和Python,自学过一些C++、C#的自动化工科生,我就这样开始了大胆的尝试。
作品完成度
如今,经过大三近2个月的努力,加上毕业这段时间对APP进行了Bug修复,我最终写完了这个APP ,并写下这篇近2万字的博客,作为我的“第二个本科毕业设计”的一个纪念。
本作品基本上实现了所有预定的功能,算是一次比较成功的尝试(对于一个刚开始对安卓开发一无所知的人来说,已经超级成功了hhh)。本项目是本人在大三寒假时开始做的,做了2个月左右,包括前期的学习和搜集资料,以及最后软件bug的修复。因为近一年比较忙,这个APP大体上完成一年多之后才来写这个博客。趁着毕业季,花了点时间修复了APP的bug,完成这篇博客,作为一个项目总结和经验分享~
需要声明的是,APP的UI部分(界面、按钮以及动画)完全是纯自己原创的,包括插图;调音器的数字信号处理部分有参考GitHub上的开源项目,做了一些修改;节拍器的代码框架也是有参考和学习GitHub国外的开源项目。(地址在结尾)
项目介绍
APP的名字:沐尘调音器
本APP是音乐类Android应用程序,主要提供调音器和节拍器功能。
开发该APP学习到的能力:Android开发的基本知识(包括Android系统基本架构、线程、接口等等)、十二平均律音乐相关知识、使用麦克风接收声音产生音频信号、数字信号处理、应用层信息传输、自定义接口、自定义线程、媒体播放、闪光灯调用、手机振动调用、可视化动画UI设计、插图设计等
本APP有原创的LOGO和UI界面,APP内包含启动界面,菜单界面,调音器界面,节拍器界面(包含三个fragment和底部菜单栏) 。用户点击图标进入APP后,会进入启动界面并保持2秒,之后自动跳转到菜单界面。菜单界面有两个按钮,分别对应调音器和节拍器。
![本科生尝试安卓APP开发:手机调音器节拍器(音乐学习者使用)](https://imgs.yssmx.com/Uploads/2023/06/503533-2.jpeg)
![本科生尝试安卓APP开发:手机调音器节拍器(音乐学习者使用)](https://imgs.yssmx.com/Uploads/2023/06/503533-3.jpeg)
![本科生尝试安卓APP开发:手机调音器节拍器(音乐学习者使用)](https://imgs.yssmx.com/Uploads/2023/06/503533-4.jpeg)
APP功能介绍
调音器
具有十二平均律调音功能,默认以440Hz为标准音,用户也可以点击按钮切换标准音为442Hz。调音器界面包含调音仪表盘、当前声音的音名、当前声音与标准音准的偏差音分数,以及一个显示当前声音音准对应位置的钢琴键盘。
进入调音器界面程序会自动请求调用手机麦克风的权限,进行收音,同时显示出当前声音的音名和音准度数。背景色块显示蓝色表示音准比较标准,在±10音分之内;显示红色表示音准不太准,需要进一步调整。校音范围是固定音C0~B8,每个八度12个半音都能识别出来。用户可以根据显示的信息,自行调整声音的音准,实现帮助用户调整音准的功能。
沐尘调音器-调音器部分
节拍器
用户可以点击界面中的按钮调整参数,点击播放按钮可以实现节拍器声音的播放和暂停。当节拍器播放时,界面中的节拍器图像会像现实中的机械节拍器一样,进行不同速度地自然摆动,同时手机会遵循节奏播放节拍器声音。在修改速度、重音、节奏型的同时,节拍器声音的播放也会同步调整(强调即时地、同步地响应参数修改的效果)
用户还可以由底部菜单栏,切换三个界面:“节拍器”、“更多、“设置””(中间的“更多”界面暂时是空白的,还没有想好要做什么内容hhh)
在设置界面中,可以开启闪光灯模式,即手机闪光灯会随节拍器的节奏闪烁;可以开启振动模式,即手机会随节拍器的节奏进行振动。还可以选择切换节拍器的音色。
![本科生尝试安卓APP开发:手机调音器节拍器(音乐学习者使用)](https://imgs.yssmx.com/Uploads/2023/06/503533-8.jpeg)
![本科生尝试安卓APP开发:手机调音器节拍器(音乐学习者使用)](https://imgs.yssmx.com/Uploads/2023/06/503533-9.jpeg)
沐尘调音器-节拍器部分
本APP的安装包下载链接:
链接:https://pan.baidu.com/s/10XFGE2-W4JnOfNzYunOJnA
提取码:3vef
注:由于懒得把UI弄成适应所有手机屏幕大小了,所以不一定能适应非全面屏的手机屏幕,若适应不了你的手机屏幕就将就着用下吧(=v=)
实际测试
实际测试的视频发在了b站: 第二个本科毕业设计|自制调音器节拍器APP_哔哩哔哩_bilibili
调音器测试
与实体电子调音器对比,本APP音高检测的灵敏度上要更好一些。
节拍器测试
速度变换、节奏型变换、重音开关、音色切换、手电筒、振动功能都正常。
项目内容
主要是分享一些功能原理、代码框架,并分享一下我从刚开始学习Android开发和Java相关语法,一直到最后开发出APP的大致经历、遇到的困难以及解决方法。本博客是纯经验分享(非教学),有错误可以在评论区补充~
开发环境
开发软件:Android Studio
开发语言:Java
插图和图标绘制软件:Adobe Illustrator 2019
SDK版本:
- compileSDK版本:32
- minSDK版本:21
- targetSDK版本:32
- JDK 版本:1.8
Android Studio、gradle和Sdk版本更新到最新版就行
APP功能原理
1.调音器-编程原理
- 调音器程序执行的大致流程:
经过计算,最终,调音器界面UI要显示的信息主要是2个:距离当前声音音高最近的标准音符、当前声音与之的音分偏差
- 快速傅里叶变换与YIN算法:
FFT(Fast Fourier Transmit),快速傅里叶变换,可以将时域转化为频域。FFT是离散傅氏变换(DFT) 的快速算法。
本项目使用了TarsosDSP库的音高估计算法FFT.YIN,其从每一帧信号FFT中获得的功率谱中提取基频,将峰值频率确定为当前声音的频率。
- 音乐理论——十二平均律:
十二平均律将一个八度分为12个音,每个音之间间隔一个半音音高。
该APP需要检测的声音音高是从C0~B8的,C0~B8 共108个音符称为标准音符(或固定音符),其中,A4又被称为国际标准音。标准音A4的频率不同,所有标准音符的频率也不同,即所有标准音符的频率是由标准音确定的。
本调音器,默认标准音是440Hz,允许用户切换标准音频率,比如用户将标准音切换至442Hz后,若要得到某个音符的频率,程序会根据当前标准音频率通过算法求出该音符的频率。下图是我自己整理的108个音名的频率-波长对照表(当标准音为440Hz时)
根据十二平均律:一个音的频率刚好是其低八度音频率的两倍,即若A4频率为440Hz,则A3频率为220Hz,A5频率为880Hz;若X音符为880Hz,Y音符比X音符高22个半音,则Y音符的频率 = 880 * 2^(22/12) = 3135.96 Hz,在上方的频率对照表里,我们可以查到X音符为A5,Y音符为G7。
由此,我们可以编写出根据标准音A4频率计算所有标准音符频率的算法:
- ValueAnimator 数值动画控制器
用来控制调音器仪表盘指针转动,并使用了自定义插值器,使动画更加丝滑。
2.节拍器-编程原理
- 节拍器程序执行的大致流程:
节拍器UI控件如下:
- ValueAnimator 数值动画控制器
用来控制节拍器摆锤转动,并使用了自定义插值器,使动画更加丝滑。
- 应用到的比较重点的操作:
(1)Thread线程类
(2)SoundPool库:用于进行7种音色音频文件的导入、播放
(3)ViewModel:用于在不同Fragment之间进行信息传递
(4)手机手电筒、振动控制:定义FlashUtils类来管理camera状态,进行手电筒的开关操作;新建Vibrator对象,设置振动频率,控制手机振动
(5)UI方面采用了1个Activity装载3个fragment,3个fragment可以来回切换显示
代码框架
1.代码文件的分类
代码文件夹主要有4个,manifest(配置文件)、java(java代码文件)、res(包含color色彩配置文件、drawable贴图文件、layout布局文件、menu菜单文件、mipmap图形文件、raw源媒体文件、values色彩尺度字符主题文件)、resources(包含一个csv文件,写有所有音符对应频率和波长的数据)
2.Manifest配置文件
配置文件里主要编写了用户权限许可声明、LOGO配置、主题配置、Activity基本信息声明
用户权限:主要是APP需要向用户申请麦克风录音和手机振动的权限
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
Activity声明:MainActivity是主界面,metro.mertoMainActivity是节拍器界面,ButtonActivity是调音器界面(开始随便命了一个名,后面懒得改了hhh),SplashActivity是启动界面
<activity
android:name=".MainActivity"
android:exported="true" />
<activity
android:name=".metro.metroMainActivity"
android:exported="false" />
<activity
android:name=".ButtonActivity"
android:exported="false" />
<activity
android:name=".SplashActivity"
android:exported="true"
android:hardwareAccelerated="false"
android:launchMode="standard"
android:configChanges="orientation|screenSize|keyboardHidden">
3.启动界面和主界面
(1)启动界面:需要写的是Java文件SplashActivity.java,还有对应的Xml布局文件
Java文件:定义SplashActivity类,并继承AppCompatActivity(Android中的Acitivity类)。在onCreate()里写的都是用户开启这个Activity之后自动运行的代码和函数。其中setContentView是指定布局文件 。定义Handler(消息传递机制),然后定义新线程run,再定义Intent,从SplashActivity(启动界面)跳转到MainActivity(主界面)。
整个功能是启动界面会显示并停留2100毫秒,然后工作线程通过Handler通知主线程(UI线程),完成界面跳转操作。
public class SplashActivity extends AppCompatActivity {
public static SplashActivity instance;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
instance=this;
setContentView(R.layout.activity_splash);
Handler handler = new Handler();
handler.postDelayed(new Runnable() {
@Override
public void run() {
Intent intent = new Intent(SplashActivity.this,MainActivity.class);
startActivity(intent);
SplashActivity.this.fileList();
instance.finish();
}
},2100);
}
Acitivity界面布局:布局在layout文件夹里新建activity_splash.xml作为该启动界面Activity对应的布局文件,可以进行自定义布局。布局文件里写了1个ImageView和3个TextView。
这个启动界面的话(包括其他的所有界面也一样)主要是采用的是垂直的相对布局(RelativeLayout),就是把每个部件与屏幕边界绑定,然后设置偏移的百分比(距离屏幕边界)。也可以是每个部件之间设置相对距离。
图片的导入:我是绘制好了之后,转成SVG(矢量图)格式,由下图的方式,在屏幕左侧drawable文件夹处右键->New新建->Vector Asset,生成单个图像的xml文件,可以直接在布局文件里使用这个图片的xml文件。
(2)主界面:需要写的是Java文件MainActivity.java,还有对应的Xml布局文件
Java文件:定义MainActivity类,主界面主要的部件是2个按钮组件(Button),我自定义了myClick函数监听按钮的触发,当按下“调音器”按钮时,运行跳转程序,跳转到调音器界面;“节拍器”按钮同上。
public void myClick(View v){
switch (v.getId()){
case R.id.btn1:
Intent intent=new Intent(MainActivity.this,ButtonActivity.class);
startActivity(intent);
break;
case R.id.btn2:
Intent intent1=new Intent(MainActivity.this, metroMainActivity.class);
startActivity(intent1);
break;
}
}
主界面还写了onKeyDown代码,定义了exit()函数,保证当用户触发退出键后,运行exit(),会发送底部消息气泡提示用户“再点击一次退出键将退出程序”。
public void exit(){
if (!isExit) {
isExit = true;
Toast.makeText(getApplicationContext(), "再按一次退出程序", Toast.LENGTH_SHORT).show();
mHandler.sendEmptyMessageDelayed(0, 2000);
} else {
Intent intent = new Intent(Intent.ACTION_MAIN);
intent.addCategory(Intent.CATEGORY_HOME);
startActivity(intent);
System.exit(0);
}
}
主界面的布局文件如下:
4.调音器界面
调音器所包含的Java文件有:ButtonActivity.java(调音器界面),CanvasPainter.java(绘制模块),ChromaticTuning.java(十二平均律调音模块),KeyView.java(钢琴键盘显示模块),ListenerFragment.java(收音模块)Note.java(接口)NoteFrequencyCalculator.java(音符频率计算)NoteName.java(枚举类,列出C0~B8的108个音符的音名和符号,包含scientific音名表示法和sol唱名表示法)PitchComparator.java(与标准音符进行比较,找出距离最近的标准音符),PitchDifference.java(计算与标准音符之间的频率差值),Sampler.java(采样器,包含每一次采样的操作,循环调用PitchDifference函数,计算所有采样信号与标准音符的频率差值,得出声音信号中出现最频繁的音符),TunerView.java(自定义View,绘制调音器界面的部分UI),Tuning.java(接口)
主要说一下声音的接收、数字信号处理,怎么计算出与当前声音差值最小的标准音高,和图形可视化部分的实现吧。
(1)声音的接收和处理:
在ListenerFragment.java文件中,创建Fragment,作为载体用来进行声音接收、处理与回调。
需要使用Java第三方库TarsosDSP,这是一个音频处理库。该库是纯 Java 实现 , 没有依赖任何外部的第三方库。
import be.tarsos.dsp.AudioDispatcher;
import be.tarsos.dsp.io.android.AudioDispatcherFactory;
import be.tarsos.dsp.pitch.PitchDetectionHandler;
import be.tarsos.dsp.pitch.PitchProcessor;
import be.tarsos.dsp.pitch.PitchProcessor.PitchEstimationAlgorithm;
- 定义PitchListener类,继承AsyncTask,在该类里写所有跟音高检测有关的代码。
AsyncTask简介:onPreExcute() (主线程中执行)、doInBackground(Params...)(子线程中执行)、如果要更新UI,可以通过调用publishProgress(Progess...)方法来完成比如反馈当前的进度、onProgressUpdate(Progress...)(主线程中执行)、onPostExecute(Result)(主线程中执行)
上面几个方法的调用顺序:onPreExcute() --> doInBackground() --> publishProgress() --> onProgressUpdate() --> onPostExcute()
在doInBackground()方法中执行接收声音信号的具体操作,在onProgressUpdate()方法中实时
AsyncTask的参考链接:八、AsyncTask解析 - 简书
调用TarsosDSP库中的类和方法:AudioDispatcher、PitchDetectionHandler
在PitchListener类里新建AudioDispatcher对象,这个对象采用默认麦克风作为声音信号来源,并设置最小item计数为15(MIN_ITEMS_COUNT =15),即采样15次为一组。之后在doInBackground(Params...)里新建PitchDetectionHandler对象,调用相关方法获取音高值(详见TarsosDSP库使用手册)。
float pitch = pitchDetectionResult.getPitch();
其中, AudioDispatcher负责切断帧中的音频,它还将音频帧包装到AudioEvent对象中。此AudioEvent对象通过AudioProcessors链发送。
使用Sampler.java里编写的calculateAverageDifference方法(注意要定义为静态),采样出的信号装入List<PitchDifference> samples(samples为由多个PitchDifference类对象组成的列表),提取采样序列中出现频率最高的音高,并采样序列进行过滤;计算过滤后序列的总偏离值(deviation),然后计算平均偏离值,最后返回。P.S 其中频率最高的音符定义为Note接口类型;提取采样序列中出现频率最高的音高是使用Sampler.java里编写的extractMostFrequentNote方法;Sampler.java里的filterByNote方法对采样序列samples进行过滤,遍历该组采样,判断距离每一个sample音高最近的标准音高是否为本组采样出现最多的音高(代码中用sample.closest表示距离sample音高最近的标准音符,其中,sample为从samples遍历出的pitchDifference对象),最后返回过滤后的采样组filteredSamples。
extractMostFrequentNote方法:定义Hash表noteFrequencies(音符频率),noteFrequenceis.containsKey(closest)判断Hash表中是否存在closest这个Key对应的映射关系(键值对映射关系),若有,取出与此声音信号音高距离最近的标准音高,获取最近标准音高的记录值(count),count+1后保存到Hash表中;若无,则将其添加到表中,count值记为1。(即对每一个sample对应的最近标准音高进行计数)
static Note extractMostFrequentNote(List<PitchDifference> samples) {
Map<Note, Integer> noteFrequencies = new HashMap<>();
for (PitchDifference pitchDifference : samples) {
Note closest = pitchDifference.closest;
if (noteFrequencies.containsKey(closest)) {
Integer count = noteFrequencies.get(closest);//获取Key对应的Value
noteFrequencies.put(closest, count + 1);//将键/值添加到Hash表中
} else {
noteFrequencies.put(closest, 1);
}
}
Note mostFrequentNote = null;
int mostOccurrences = 0;
//noteFrequencies.keySet()返回表中Key的集合
for (Note note : noteFrequencies.keySet()) {
Integer occurrences = noteFrequencies.get(note);
if (occurrences > mostOccurrences) {
mostFrequentNote = note;
mostOccurrences = occurrences;
}
}
return mostFrequentNote;
}
注意下面定义的calculateAverageDifference方法是返回值为PitchDifference类的方法。
static PitchDifference calculateAverageDifference(List<PitchDifference> samples) {
Sampler.calculateAverageDifference()返回对采样序列pitchDifferences计算出的出现最多的音符(mostFrequentNote)和平均音准偏差(averageDeviation)所新建的PitchDifference对象。
return new PitchDifference(mostFrequentNote, averageDeviation);
ListenerFragment.java中,每组采样得到上述返回的新建PitchDifference对象average,并对其调用publishProgress(average),publishProgress()用来在PitchListener类(继承了AsyncTask)中更新UI。
if (pitchDifferences.size() >= MIN_ITEMS_COUNT) {
PitchDifference average =
Sampler.calculateAverageDifference(pitchDifferences);
publishProgress(average);
pitchDifferences.clear();
}
创建pitchProcessor对象,初始化设置采样频率为44100,缓冲区大小1024*4,重叠区768*4,音高估计算法设置为PitchEstimationAlgorithm.FFT_YIN(一种快速傅里叶变换算法)。接着初始化audioDispatcher对象,将新建的pitchProcessor添加到audioDispatcher对象中,运行audioDispatcher。
接着在PitchListener类(继承了AsyncTask)中的onProgressUpdate()方法里调用接口taskCallbacks的onProgressUpdate()方法,即在主线程中运行
P.S PitchListener类(自己定义的)定义如下:
private static class PitchListener extends AsyncTask<Void, PitchDifference, Void> {
private AudioDispatcher audioDispatcher;
@Override
protected Void doInBackground(Void... params) {
PitchDetectionHandler pitchDetectionHandler = (pitchDetectionResult, audioEvent) -> {
if (isCancelled()) {
stopAudioDispatcher();
return;
}
if (!IS_RECORDING) {
IS_RECORDING = true;
publishProgress();
}
float pitch = pitchDetectionResult.getPitch();
if (pitch != -1) {
PitchDifference pitchDifference = PitchComparator.retrieveNote(pitch);
pitchDifferences.add(pitchDifference);
if (pitchDifferences.size() >= MIN_ITEMS_COUNT) {
PitchDifference average =
Sampler.calculateAverageDifference(pitchDifferences);
publishProgress(average);
pitchDifferences.clear();
}
}
};
PitchProcessor pitchProcessor = new PitchProcessor(PitchEstimationAlgorithm.FFT_YIN,
SAMPLE_RATE,
BUFFER_SIZE, pitchDetectionHandler);
audioDispatcher = AudioDispatcherFactory.fromDefaultMicrophone(SAMPLE_RATE,
BUFFER_SIZE, OVERLAP);
audioDispatcher.addAudioProcessor(pitchProcessor);
audioDispatcher.run();
return null;
}
@Override
protected void onProgressUpdate(PitchDifference... pitchDifference) {
if (taskCallbacks != null) {
if (pitchDifference.length > 0) {
taskCallbacks.onProgressUpdate(pitchDifference[0]);
} else {
taskCallbacks.onProgressUpdate(null);
}
}
}
@Override
protected void onCancelled(Void result) {
stopAudioDispatcher();
}
private void stopAudioDispatcher() {
if (audioDispatcher != null && !audioDispatcher.isStopped()) {
audioDispatcher.stop();
IS_RECORDING = false;
}
}
}
}
然后在ListenerFragment中的onCreate方法里创建PitchListener对象,执行对象所带有的内容。
- 定义接口TaskCallbacks,定义方法onProgressUpdate,调用。TaskCallbacks接口是在ButtonActivity类中实现的。
下面给出这个接口与Java类的关系示意图:
(2)如何计算出与当前声音频率差值最小的标准音高:
在上一步中我们可以得到当前麦克风所接收声音的频率,我们需要得到它离哪一个标准音符最近,相差多少频率。
在PitchDifference.java文件中,定义了一个PitchDifference抽象类,并implements Parcelable接口。Parcelable是Android提供的序列化接口,实现序列化和反序列化的操作。
为什么要用Parcelable呢?Parcelable作用是利用Parcel out 将数据存储到内存中,然后通过Parcel in 从内存中获取数据。进行Android开发的时候,无法将对象的引用传给Activities,我们需要将这些对象放到一个Intent或者Bundle里面,然后再传递。简单来说就是将对象转换为可以传输的二进制流(二进制序列)的过程,这样我们就可以通过序列化,转化为可以在网络传输或者保存到本地的流(序列),从而进行传输数据 ,那反序列化就是从二进制流(序列)转化为对象的过程。Parcel提供了一套机制,可以将序列化之后的数据写入到一个共享内存中,其他进程通过Parcel可以从这块共享内存中读出字节流,并反序列化成对象。
PitchDifference类的成员变量是Note型的closest,即距离最近的标准音符(Note是在Note.java文件中自定义的接口)和double型的deviation,即距离标准音符的偏差值。
createFromParcel(Parcel in) 从Parcel容器中读取传递数据值,封装成Parcelable对象返回逻辑层。newArray(int size) 创建一个类型为T,长度为size的数组,仅一句话(return new T[size])即可(暂时感觉没用)。
private PitchDifference(Parcel in){...},是在创建构造方法,实例化对象。
writeToParcel 方法,将对象写入序列化,即打包需要传递的数据到Parcel容器保存。
class PitchDifference implements Parcelable {
public static final Creator<PitchDifference> CREATOR = new Creator<PitchDifference>() {
public PitchDifference createFromParcel(Parcel in) {
return new PitchDifference(in);
}
public PitchDifference[] newArray(int size) {
return new PitchDifference[size];
}
};
final Note closest;
final double deviation;
PitchDifference(Note closest, double deviation) {
this.closest = closest;
this.deviation = deviation;
}
private PitchDifference(Parcel in) {
Tuning tuning = ButtonActivity.getCurrentTuning();
closest = tuning.findNote(in.readString());
deviation = in.readDouble();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(closest.getName().name());
dest.writeDouble(deviation);
}
}
不了解Parcelable的话可以参考一下下面的博文:
第二章——Parcelable接口的使用(跨进程,Intent传输)_dianqu7487的博客-CSDN博客
NoteFrequencyCalculator.java:用来计算改变标准音之后的所有标准音符C0~B8的固定频率(比如从440Hz切换到442Hz后)
NoteName.java中定义了枚举类NoteName,CDEFGAB 7个音名对应的两种表示法:科学表示法(scientific,用音名表示音符)+唱名表示法(sol,用唱名表示音符)
C("C", "Do"),
D("D", "Re"),
E("E", "Mi"),
F("F", "Fa"),
G("G", "Sol"),
A("A", "La"),
B("B", "Si");
private final String scientific;
private final String sol;
Tuning.java中定义了接口Tuning,包含返回Note[]型的getNotes()方法,还有通过音名查找的方法findNote()。
public interface Tuning {
Note[] getNotes();
Note findNote(String name);
}
Note.java中定义了接口Note,包含返回音符名的getName()方法 ,返回八度记号(octave)的getOctave()方法,以及返回升降号标记(Sign)的getSign()方法。
public interface Note {
NoteName getName();
int getOctave();
String getSign();
}
在ChromaticTuning.java文件里,定义ChromaticTuning类实现接口Tuning,@Override重写接口中的方法getNotes()和findNote()。Pitch.values()用来将枚举类转变为一个枚举类型的数组;Pitch.valueOf(name)输入string型的音名name然后输出对应的枚举常量对象。
@Override
public Note[] getNotes() {
return Pitch.values();
}
@Override
public Note findNote(String name) {
return Pitch.valueOf(name);
}
其中又定义一个枚举类Pitch实现接口Note。getNotes()返回枚举类型的对象数组,该方法可以很方便地遍历所有的枚举值。下面代码为枚举类中列出的-1八度的12个音符的详细信息(包含音名、octave和升降号),第-1~第9八度共120个音符都需要枚举出来。注意:音名name需要定义为NoteName类(NoteName是自定义的枚举类,用于选择性用两种命名法表示音符的音名)
C_MINUS_1(C, -1),
C_MINUS_1_SHARP(C, -1, "#"),
D_MINUS_1(D, -1),
D_MINUS_1_SHARP(D, -1, "#"),
E_MINUS_1(E, -1),
F_MINUS_1(F, -1),
F_MINUS_1_SHARP(F, -1, "#"),
G_MINUS_1(G, -1),
G_MINUS_1_SHARP(G, -1, "#"),
A_MINUS_1(A, -1),
A_MINUS_1_SHARP(A, -1, "#"),
B_MINUS_1(B, -1),
PitchComparator.java中,定义了PitchComparator类
retrieveNote():从ButtonActivity获取当前选择的调音模式,新建十二平均律调音(Chromatic Tuning)对应的Tuning接口对象,并从ButtonActivity获取当前选择的参考标准音(reference Pitch)。创建NoteFrequencyCalculator对象,用于计算当前参考标准音下所有标准音符的具体频率。最后求出距离采样音符组note最近的标准音符(closest),以及最小的音分偏差(minCentDifference)。最后,retrieveNote()方法返回用这两者新建的PitchDifference对象。
class PitchComparator {
static PitchDifference retrieveNote(float pitch) {
...
double minCentDifference = Float.POSITIVE_INFINITY;
Note closest = notes[0];
for (Note note : notes) {
double frequency = noteFrequencyCalculator.getFrequency(note);
double centDifference = 1200d * log2(pitch / frequency);
if (Math.abs(centDifference) < Math.abs(minCentDifference)) {
minCentDifference = centDifference;
closest = note;
}
}
return new PitchDifference(closest, minCentDifference);
...
private static double log2(double number) {
return Math.log(number) / Math.log(2);
}
}
为什么要定义Tuning接口,然后通过ChromaticTuning类来实现接口所预设的功能?
实际上是本项目参考的GitHub项目,原本是考虑设定不同乐器有其特定的调音模式,可以让用户自由选择不同的调音模式。用户可以选择不同的调音模式,选定某个调音模式,程序进行监听,新建目标模式对应的调音类(比如十二平均律调音模型就对应ChromaticTuning类)。每一种调音类都implements Tuning接口,比如:public class ChromaticTuning implements Tuning(){...}
这样就可以分调音模式,实现不同的调音功能(但本APP没有设计这一切换调音模式的功能,主要是感觉能把基础的十二平均律调音模式写好都不错了hhh)
(3)调音器界面的UI设计:
调音器界面的布局文件如下:
在调音器界面(本项目代码将其随便命名为ButtonActivity)的Acitivity里,询问用户获取录音权限,创建ListenerFragment对象,开始录音,并进行数字信号处理,得到声音频率信息;之后通过PitchDifference、PitchComparator类编写的算法,根据设定的标准音频率(440Hz或442Hz),获取距离此时声音音高最近的标准音符和最小音分偏差,然后将这俩信息还有当前声音频率对应音分实时更新到UI上,不断循环此过程。
ButtonActivity.java文件主要来编写UI更新与监听代码。在 ButtonActivity类里实现TaskCallBacks接口,用于实时更新UI。
- 调音器界面放置了2个SwitchButton,用户可以选定标准音频率和音名表示方法。
- 对于UI的绘制:
定义了两个自定义View:TunerView.java(绘制仪表盘界面)、KeyView.java(绘制底部的小钢琴键盘)
还有CanvasPainter.java 用于初始化画笔参数
主要使用了canvas库来实时绘制图像,并利用矩阵旋转,来实现仪表盘指针的旋转。
未完待续~
5.节拍器界面
(1)音频有节奏播放
HomeFragment.java 主循环主要都是在监听普通按钮(Button)和切换按钮(Switch Button)。
定义Tick类并继承Thread类(线程类),在Thread类的run()方法中编写需要循环执行的程序(也就是周期性不断播放节拍器声音)。
class Tick extends Thread {
protected int tempo, subdiv, nTicks,beats,soundNum;
protected long startTime, nextTime;
boolean keepGoing;
long detime;
boolean pendl;
public Tick(int tempo, int beats,int subdiv,int soundNum) {
this.tempo = tempo;
this.beats = beats;
this.subdiv = subdiv;
this.soundNum = soundNum;
keepGoing = true;
}
@Override public void run() {
nTicks = 0;
startTime = System.currentTimeMillis();
nextTime = startTime;
pendl=true;
while (keepGoing) {
......
}
......
}
(2)参数实时更新
问题:如何实现用户点击按钮后,能够瞬间感受到参数改变带来的效果?
编写实时改变参数的方法。在按钮被点击之后,监听程序调用该方法,来修改tick对象中的参数,这样线程对象tick就会在循环中,使用新的参数来进行音频播放。
(3)不同Fragment之间信息传输
问题:如何实现在第三个Fragment(设置界面)里点击多选项按钮修改音色后,第一个Fragment能够即时接收到该信号,对线程对象tick中的相应参数进行修改?
使用SoundPool进行音频播放,是通过切换音频ID来实现播放不同音频文件的。因此,可以定义一个新的SoundNum类,用来储存SoundNumber也就是音频ID(int型)。定义ViewModel类,创建一个ViewModel对象myViewModel,将SoundNum对象绑定在myViewModel中,来监听SoundNum对象数据是否变化:若在第三个Fragment里修改了SoundNum对象的数据,则myViewModel可以监听到,然后可以在第一个Fragment里编写监听后的操作。
P.S. 注意myViewModel的实例化要在@override onActivityCreated(Bundle savedInstanceState){...}里进行。
public class HomeFragment extends BaseFragment{
private MyViewModel myViewModel;
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
myViewModel = new ViewModelProvider(
requireActivity(),
new ViewModelProvider.NewInstanceFactory()).get(MyViewModel.class);
myViewModel.mUserLiveData.observe(getActivity(), new Observer<SoundNum>() {
@Override
public void onChanged(SoundNum soundNum) {
tick.soundNum = soundNum.soundNum;
}
});
}
}
节拍器UI部分:
metroMainActivity(节拍器主界面)布局文件如下:
HomeFragment(节拍器显示界面)布局文件 如下:
ThirdFragment(设置界面)布局文件 如下:
项目总结
遇到的问题
在fragment里需要用this、载体之类的变量类型,可以尝试用getActivity()
未完待续~
总结
未完待续~
本项目开源地址:沐尘调音器-节拍器: 本APP是音乐类Android应用程序,主要提供调音器和节拍器功能。
本项目所参考的开源项目,以及使用的第三方库TarsosDSP的地址如下,大家可以了解一下:
GitHub - gstraube/cythara: A musical instrument tuner for Android文章来源:https://www.toymoban.com/news/detail-503533.html
GitHub - JorenSix/TarsosDSP: A Real-Time Audio Processing Framework in Java文章来源地址https://www.toymoban.com/news/detail-503533.html
到了这里,关于本科生尝试安卓APP开发:手机调音器节拍器(音乐学习者使用)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!