Silverlight 发音测试





5.00/5 (12投票s)
如何使用 Silverlight 和 Python 创建发音测试工具
目录
引言
写这篇文章以及随附应用程序的原因是我一直以来都在构思一个自动发音测试的想法。由于遇到的困难和一些挫折,我曾三四次想放弃,但最终“永不放弃”的声音占了上风并最终获胜。幸运的是,我最终找到了一个解决方案,我承认它并不完美,并且与我最初的“开发轨道”相去甚远。但当你遇到困难时,往往就是这样,你会拥抱任何对你有用的工具。
第一个问题是找到代码或组件来生成用于分析的所谓“音高轮廓”。音高轮廓是人类语音的旋律,更技术上说是伴随语音的频率波动。我努力寻找包含音高轮廓计算的“开源 .Net 代码”,搜索互联网,但没有成功。我发现了一些开源解决方案,但它们不是用 .Net 代码编写的(主要是 C++/Python)。遗憾的是,我不是 C++ 或 Python 专家,而且代码量太大无法移植。另外,我也不擅长创建全新库所需的数学算法(如快速傅里叶变换)。因此,我最终得到了一个服务器端 C# 程序和控制台 Python 应用程序之间的“协作”。这并不是特别理想,因为我最初计划的是完全客户端、托管代码,但它有效,这才是最重要的。我希望 .Net 社区中的一些代码英雄能提出一个更优雅的解决方案。
第二个问题是尝试将用户的语音与预定义的练习语音进行比较并给出分数。我如何比较两个音高轮廓?我没有为此任务的工具,所以不得不自己想出一个新的。这花费了我很多工作时间,而且仍然不完美,但这是我迄今为止唯一能做到的。就像上一个问题一样,这里的代码英雄可以拯救一天。
系统要求
请确保您按照这 3 个步骤操作
1. 运行本文提供的发音测试需要以下软件
- Visual Studio 2010 或 Visual C# Web Developer
2. 另外,您必须下载 Python 2.2 软件并**确保它安装在 C:\Python22 文件夹中。** 这是因为源代码只能与存储在 C:\Python22 文件夹中的应用程序一起使用。由于该应用程序仅使用 Python 2.2 开发,因此我无法确定它是否适用于其他版本的 Python。
3. 最后,您必须下载 **snack2.2.zip** 文件并将它们复制到 **C:\Python22\tcl** 文件夹。没有这个文件夹,应用程序将无法工作。
用户界面
用户界面是 100% Silverlight。它很干净,我必须承认,在某种程度上受到了 Metro 设计的启发。按钮执行非常基本的功能:移至上一题和下一题练习,播放示例语音和用户语音,以及录制用户语音。
与许多 XAML 项目一样,这个项目也使用了 MVVM(Model-View-ViewModel)模式。简而言之,按钮没有“单击事件”的代码隐藏,也没有“object.property = new value”的指令(实际上,有几个事件处理程序,但仅在无法使用 MVVM 的情况下)。相反,我们的按钮使用 MVVM 风格
播放示例语音
为此应用程序,我包含了 2 段语音,这些语音来自 Free Sound 网站,因此关于这些语音的版权没有问题。我只包含 2 个示例文件,以启用下一个/上一个功能并将 .zip 源代码保持尽可能小。这些文件是 **sample01.wav** 和 **sample02.wav**,位于 **PitchContour.Web\Files\sample01.wav** 文件夹。
如果您想更改或添加更多示例音频文件,但请注意,有一些条件必须满足
- 文件必须具有 **.wav** 扩展名。
- 文件必须是 **单声道**。
这些要求是由我为应用程序选择的工具强加的。如果您有兴趣添加不符合这些条件的文件,或者录制您自己的声音,那么您可能对安装 Audacity 感兴趣,这是一个用于录制/编辑音频的优秀免费工具。
但是我们实际上如何在应用程序中播放 **示例语音**?首先,我们有一个标准的
<MediaElement x:Name="sampleVoiceMediaElement" Width="450" Height="250" Stretch="Fill" AutoPlay="True"
Position="{Binding SampleVoiceMediaPosition, Mode=TwoWay}" MediaOpened="sampleVoiceMediaElement_MediaOpened"/>
在上面的代码片段中,我们可以注意到 MediaElement 的
让我们先看看
private void sampleVoiceMediaElement_MediaOpened(object sender, RoutedEventArgs e)
{
viewModel.SampleVoiceDuration = this.sampleVoiceMediaElement.NaturalDuration;
}
现在我们知道了时长,我们就可以计算 MediaElement 的百分比了
public TimeSpan SampleVoiceMediaPosition
{
get
{
return sampleVoiceMediaPosition;
}
set
{
sampleVoiceMediaPosition = value;
NotifyPropertyChanged("SampleVoiceMediaPosition");
if (sampleVoiceDuration.HasTimeSpan)
{
if (sampleVoiceDuration.TimeSpan.TotalMilliseconds > 0)
{
var x = (double)(value.TotalMilliseconds / sampleVoiceDuration.TimeSpan.TotalMilliseconds)
* CANVAS_WIDTH;
SampleVoiceMediaBorderWidth = x;
}
}
}
}
现在
public double SampleVoiceMediaBorderWidth
{
get
{
return sampleVoiceMediaBorderWidth;
}
set
{
sampleVoiceMediaBorderWidth = value;
NotifyPropertyChanged("SampleVoiceMediaBorderWidth");
}
}
<Border x:Name="brdSampleVoiceCursor" BorderBrush="DarkGreen"
BorderThickness="1" Height="100" Width="{Binding SampleVoiceMediaBorderWidth, Mode=TwoWay}"
HorizontalAlignment="Left" VerticalAlignment="Center">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Offset="0" Color="#fff"/>
<GradientStop Offset="0.5" Color="#8f8"/>
<GradientStop Offset="1" Color="#8f8"/>
</LinearGradientBrush>
</Border.Background>
</Border>
简而言之:当
录制用户语音
网上有一些涉及音频录制和 Silverlight 的解决方案。我特别喜欢 Ondrej Svacina 的博客提出的解决方案。我必须说,对于音频录制部分,我只是复制了他的代码,但最终我们的界面存在一些明显的差异
- Ondrej 的代码允许本地下载音频文件(我的代码只将其上传到服务器)。
- 他包含了一对按钮用于启动/停止录音机。我的有一个单开关录音按钮。
- 他的界面显示一个模拟计数器来跟踪录音机进度(我的则没有)。
您只需单击录音按钮即可开始录制您的声音,然后再次单击即可停止录制
将用户语音文件上传到服务器
一旦语音被录制,应用程序就会开始将其上传到服务器。对于此功能,我最初没有自己的代码,因此不得不求助于已经完成此功能的人。这就是为什么我选择了 Michael Washington 的精彩文章*《Silverlight 简单的拖放或浏览视图模型/MVVM 文件上传控件》*。尽管 Michael 的最初文章的目标与我的非常不同,但幸运的是,它为我提供了执行语音上传功能所需的精美的 Silverlight 和 Web 服务器管道。
提取音高轮廓
正如我在文章开头提到的,不幸的是,我没有找到或构思出一种托管代码来从 .wav 语音文件中提取音高轮廓。但即便如此,我还是找到了一个解决方案,使用了 Snack 和一个小型 Python 程序通过命令行。用他们自己的话来说
“Snack Sound Toolkit 旨在与脚本语言(如 Tcl/Tk 或 Python)一起使用。使用 Snack,您只需几行代码即可创建强大的跨平台音频应用程序。Snack 具有基本声音处理命令,如播放、录制、文件和套接字 I/O。Snack 还提供声音可视化的基本功能,例如波形和频谱图。它主要用于处理数字语音录音,但同样适用于通用音频。Snack 也已成功应用于其他一维信号。Snack 与脚本语言的结合使得用最少的精力创建声音工具和应用程序成为可能。这是因为脚本语言的快速开发特性。作为奖励,您可以获得一个跨平台应用程序。集成基于 Snack 的应用程序与现有声音分析软件也很容易。”
音高轮廓提取是通过一个用 Python 编写的脚本完成的
from Tkinter import *
import tkSnack
import pickle
class Speech:
def Analyze(self, inputFile, outputFile):
root = Tk()
tkSnack.initializeSnack(root)
mySound = tkSnack.Sound()
mySound = tkSnack.Sound(load=inputFile)
f = open(outputFile, "w")
data = mySound.pitch()
pickle.dump(data, f)
f.close()
return()
speech = Speech()
speech.Analyze('{source}', "{destination-pitch}")
上面的脚本非常简单:首先,它引用了库(tkSnack、pickle)。然后创建了一个新的实例
这个目标文件将包含一个值列表,表示音高的变化,即频率的变化。正如预期的那样,男性声音的平均值将低于女性声音。这些值将由应用程序稍后读取并在波形上显示。这就是结果的 .txt 音高文件的样子(每个值前面都有一个 'F' 字母)
(F0.0
F0.0
F0.0
F0.0
F0.0
F0.0
F0.0
F0.0
F0.0
F216.0
F214.0
F212.0
F213.0
F212.0
F210.0
F204.0
F206.0
F202.0
F196.0
F190.0
F178.0
F160.0
F0.0
F0.0
F0.0
F0.0
F0.0
F0.0
F0.0
F222.0
.
.
.
F0.0
tp0
.
但正如我们之前提到的,Python 代码不是由 .Net 应用程序直接调用的。相反,我们实例化一个
public void GeneratePitchFile()
{
var pythonFolder = ConfigurationManager.AppSettings["PythonFolder"];
var extractPitchProgram = ConfigurationManager.AppSettings["ExtractPitchProgram"];
var pythonExe = System.IO.Path.Combine(pythonFolder, "python.exe");
var extractPitchDestinationPath = System.IO.Path.Combine(pythonFolder,
string.Format(@"lib\{0}", extractPitchProgram));
var pitchResultPath = filePath.Replace(".wav", ".txt");
var waveResultPath = filePath.Replace(".wav", "-wave.txt");
using (var sr = new StreamReader(Path.Combine(appFolder, "ExtractPitch.py")))
{
using (var sw = new StreamWriter(extractPitchDestinationPath, false))
{
var fileString = sr.ReadToEnd()
.Replace("{source}", filePath.Replace(@"\", @"\\"))
.Replace("{destination-pitch}", pitchResultPath.Replace(@"\", @"\\"))
.Replace("{destination-wave}", waveResultPath.Replace(@"\", @"\\"));
sw.Write(fileString);
}
}
Process process = Process.Start(pythonExe, extractPitchDestinationPath);
process.EnableRaisingEvents = true;
process.Exited += (sender, args) =>
{
process.Close();
};
}
有人可能会争辩说,Python 代码可以被移植到 **Iron Python** 以直接与 .Net 代码一起使用。事实上,我尝试过,但它不起作用,因为
提取波形
波形直接从 .wav 文件中提取。我使用了用户 **pj4533** 在《显示波形》文章中提供的代码
public ObservableCollection<int> GetPoints(double canvasWidth, double canvasHeight)
{
Read();
var points = new ObservableCollection<int>();
short val = m_Data[0];
int prevX = 0;
canvasHeight = CANVASHEIGHT;
int prevY = (int)(((val + 32768) * canvasHeight) / 65536);
for (int i = 0; i < m_Data.NumSamples; i += 16)
{
val = m_Data[i];
int scaledVal = (int)(((-val - 32768) * canvasHeight) / 65536);
points.Add(scaledVal);
prevX = i;
prevY = scaledVal;
if (m_Fmt.Channels == 2)
i++;
}
return points;
}
值得注意的是,音高轮廓和波形都仅在音频文件上传后才提取。
显示音高轮廓
我们有一个
<Path x:Name="pthPitchCurve" Height="100" Width="500" Stroke="#f00" StrokeThickness="2" Data="{Binding SampleVoicePitchData}"
HorizontalAlignment="Left" Stretch="None"></Path>
Path 元素的
private string GeneratePitchData(ArrayOfInt pitchValues, int offset, double xAdjustFactor)
{
var sb = new StringBuilder();
if (pitchValues.Count() > 0)
{
double minPoint = pitchValues.Min();
double maxPoint = pitchValues.Max();
double absMaxPoint = Math.Abs(minPoint) > maxPoint ?
Math.Abs(minPoint) : maxPoint;
double xScale = (CANVAS_WIDTH / pitchValues.Count()) * xAdjustFactor;
double yScale = CANVAS_HEIGHT / (maxPoint - minPoint);
yScale = PITCHDATAYSCALE;
var lastYValue = 0;
var x = 0;
foreach (var pitch in pitchValues)
{
var yValue = pitch;
var y = LINEBASE - yValue;
if (yValue > 0)
{
if (lastYValue == 0)
{
var pointM = string.Format("M{0},{1} ", (int)(offset + x * xScale),
(int)(y * yScale));
sb.Append(pointM);
}
var pointL = string.Format("{0},{1} ", (int)(offset + x * xScale),
(int)(y * yScale));
sb.Append(pointL);
}
lastYValue = yValue;
x++;
}
}
else
{
DispatcherTimer pitchDataTimer = new DispatcherTimer();
pitchDataTimer.Interval = TimeSpan.FromMilliseconds(1000);
pitchDataTimer.Tick += (s, e) =>
{
pitchDataTimer.Stop();
DoGetSampleVoicePitchData(false);
};
pitchDataTimer.Start();
}
return sb.ToString();
}
因此,音高轮廓显示在
显示波形
波形的显示方式非常相似:我们有一个另一个
<Path x:Name="pthWave" Height="100" Width="500" Stroke="#aaa" Data="{Binding SampleVoiceWavePath}"
HorizontalAlignment="Left" VerticalAlignment="Center" Stretch="None"></Path>
该
private string GenerateWavePath(ArrayOfInt points)
{
double minPoint = points.Min();
double maxPoint = points.Max();
double middlePoint = maxPoint - minPoint / 2;
double absMaxPoint = Math.Abs(minPoint) > maxPoint ? Math.Abs(minPoint) : maxPoint;
double xScale = CANVAS_WIDTH / points.Count();
double yScale = CANVAS_HEIGHT / ((maxPoint - minPoint));
var sbUserVoiceWavePath = new StringBuilder();
var yWave = points[0];
sbUserVoiceWavePath.AppendFormat("M{0},{1} ", 0, (int)(CANVAS_HEIGHT / 2));
for (var xWave = 1; xWave < points.Count(); xWave++)
{
yWave = (int)(points[xWave]);
var x = string.Format("{0:0.00}", xWave * xScale).Replace(",", ".");
var y = string.Format("{0:0.00}", (yWave - minPoint) * yScale).Replace(",", ".");
sbUserVoiceWavePath.AppendFormat("L{0},{1} ", x, y);
}
return sbUserVoiceWavePath.ToString();
}
然后这就是波形的样子
计算分数
现在我们有了所有数据(示例语音和用户语音的音高轮廓和波形),计算分数就看我们的了。假设分数范围从最低的 0 分到最高的 100 分(表示发音完美),我们必须定义如何衡量这个尺度。
如前所述,我对音频分析一无所知,所以我发明了一种方法,将音高轮廓分解成单个片段并计算每个片段的斜率。也就是说,一个片段可以向上或向下。整个示例语音的音高轮廓将有一个斜率集合,例如“下-上-下-下-上-下-上”,而用户语音将有另一组斜率,例如“下-下-上-下-下-上-上-下”。然后我们比较这些集合并提供一个从 0 到 100 分的分数,其中 0 表示没有匹配,100 表示所有片段斜率都匹配。通过下面图像中的红色和蓝色箭头,您可以看到哪些斜率正在下降或上升
下面是计算音高轮廓比较等级的主要代码
private void GenerateGrade()
{
var segmentSlopeScore = 0.0;
var samplePitchValuesLength = GetLastX(this.sampleVoicePitchValues) -
GetFirstX(this.sampleVoicePitchValues);
var userPitchValuesLength = GetLastX(this.userVoicePitchValues) -
GetFirstX(this.userVoicePitchValues);
var pitchValuesLengthError = (double)Math.Abs(userPitchValuesLength -
samplePitchValuesLength) / samplePitchValuesLength;
var sampleSegments = GetPitchSegmentLengthList(this.sampleVoicePitchValues).Where(v => v > 0).ToList();
var userSegments = GetPitchSegmentLengthList(this.userVoicePitchValues).Where(v => v > 0).ToList();
var segmentIndex = 0;
var validSegmentCount = 0;
RemoveNaNSegments(userSlicedSlopes, userSegments);
if (sampleSegments.Count() > userSegments.Count())
{
RemoveInconsistentSegments(sampleSlicedSlopes, userSlicedSlopes, sampleSegments, userSegments);
}
else if (userSegments.Count() > sampleSegments.Count())
{
RemoveInconsistentSegments(userSlicedSlopes, sampleSlicedSlopes, userSegments, sampleSegments);
}
foreach (var sampleSegment in sampleSegments)
{
if (sampleSegment > 0)
{
if (userSlicedSlopes.Count() > segmentIndex)
{
var currentSampleSlope = sampleSlicedSlopes[segmentIndex];
var currentUserSlope = userSlicedSlopes[segmentIndex];
if (!double.IsNaN(currentSampleSlope) && !double.IsNaN(currentUserSlope))
{
if (CheckSlopes(currentSampleSlope, currentUserSlope))
segmentSlopeScore++;
}
segmentIndex++;
validSegmentCount++;
}
}
}
var sampleSegmentCount = GetPitchSegmentLengthList(this.sampleVoicePitchValues).Count();
var userSegmentCount = GetPitchSegmentLengthList(this.userVoicePitchValues).Count();
var segmentCountError = (double)Math.Abs(userSegments.Count() - sampleSegments.Count())
/ sampleSegmentCount;
Grade = (int)((segmentSlopeScore / validSegmentCount) * 100.0 * (1.0 - segmentCountError));
}
显示分数
正如我们之前对音高轮廓和波形所做的那样,分数是通过将 XAML 端的视觉元素绑定到 ViewModel 类中的给定属性来显示的
<TextBlock x:Name="txtGrade" Text="{Binding Grade}" Foreground="Green" FontSize="45" TextAlignment="Center" VerticalAlignment="Center">
</TextBlock>
该
public int Grade
{
get
{
return grade;
}
set
{
grade = value;
NotifyPropertyChanged("Grade");
}
}
最终思考
我希望您喜欢这篇文章。虽然我希望它对您有用,但正如您所见,仍有很大的改进空间,如果您有什么要说的,请在下方留言。
历史
- 2012-04-29:初始版本。