65.9K
CodeProject 正在变化。 阅读更多。
Home

Silverlight 发音测试

starIconstarIconstarIconstarIconstarIcon

5.00/5 (12投票s)

2012年4月30日

CPOL

11分钟阅读

viewsIcon

48336

downloadIcon

1747

如何使用 Silverlight 和 Python 创建发音测试工具

下载 PitchContour.zip 

下载 snack2.2.zip

目录

引言

写这篇文章以及随附应用程序的原因是我一直以来都在构思一个自动发音测试的想法。由于遇到的困难和一些挫折,我曾三四次想放弃,但最终“永不放弃”的声音占了上风并最终获胜。幸运的是,我最终找到了一个解决方案,我承认它并不完美,并且与我最初的“开发轨道”相去甚远。但当你遇到困难时,往往就是这样,你会拥抱任何对你有用的工具。

第一个问题是找到代码或组件来生成用于分析的所谓“音高轮廓”。音高轮廓是人类语音的旋律,更技术上说是伴随语音的频率波动。我努力寻找包含音高轮廓计算的“开源 .Net 代码”,搜索互联网,但没有成功。我发现了一些开源解决方案,但它们不是用 .Net 代码编写的(主要是 C++/Python)。遗憾的是,我不是 C++ 或 Python 专家,而且代码量太大无法移植。另外,我也不擅长创建全新库所需的数学算法(如快速傅里叶变换)。因此,我最终得到了一个服务器端 C# 程序和控制台 Python 应用程序之间的“协作”。这并不是特别理想,因为我最初计划的是完全客户端、托管代码,但它有效,这才是最重要的。我希望 .Net 社区中的一些代码英雄能提出一个更优雅的解决方案。

第二个问题是尝试将用户的语音与预定义的练习语音进行比较并给出分数。我如何比较两个音高轮廓?我没有为此任务的工具,所以不得不自己想出一个新的。这花费了我很多工作时间,而且仍然不完美,但这是我迄今为止唯一能做到的。就像上一个问题一样,这里的代码英雄可以拯救一天。

系统要求

请确保您按照这 3 个步骤操作

1. 运行本文提供的发音测试需要以下软件

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 风格命令,其他视觉元素的属性绑定到底层ViewModel类。

播放示例语音

为此应用程序,我包含了 2 段语音,这些语音来自 Free Sound 网站,因此关于这些语音的版权没有问题。我只包含 2 个示例文件,以启用下一个/上一个功能并将 .zip 源代码保持尽可能小。这些文件是 **sample01.wav** 和 **sample02.wav**,位于 **PitchContour.Web\Files\sample01.wav** 文件夹。

如果您想更改或添加更多示例音频文件,但请注意,有一些条件必须满足

  • 文件必须具有 **.wav** 扩展名。
  • 文件必须是 **单声道**。

这些要求是由我为应用程序选择的工具强加的。如果您有兴趣添加不符合这些条件的文件,或者录制您自己的声音,那么您可能对安装 Audacity 感兴趣,这是一个用于录制/编辑音频的优秀免费工具。

但是我们实际上如何在应用程序中播放 **示例语音**?首先,我们有一个标准的MediaElement控件直接在 XAML 代码中,用于播放示例语音

<MediaElement x:Name="sampleVoiceMediaElement" Width="450" Height="250" Stretch="Fill" AutoPlay="True" 
Position="{Binding SampleVoiceMediaPosition, Mode=TwoWay}" MediaOpened="sampleVoiceMediaElement_MediaOpened"/>

在上面的代码片段中,我们可以注意到 MediaElement 的职位属性绑定到SampleVoiceMediaPosition底层 ViewModel 类的属性。此外,MediaOpened事件由sampleVoiceMediaElement_MediaOpened代码隐藏类中的函数处理。

让我们先看看MediaOpened事件。在媒体打开之前,我们不知道(也仍然无法访问)媒体时长(计算和定位播放光标需要时长)的值,因此我们必须读取此值并将其存储在 ModelView 实例中

    private void sampleVoiceMediaElement_MediaOpened(object sender, RoutedEventArgs e)
    {
        viewModel.SampleVoiceDuration = this.sampleVoiceMediaElement.NaturalDuration;
    }

现在我们知道了时长,我们就可以计算 MediaElement 的百分比了职位相对于此时长,然后绘制一个绿色矩形来表示播放器的进度。但在此之前,我们必须先将计算结果存储在另一个属性(SampleVoiceMediaBorderWidth)中ViewModel实例

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;
            }
        }
    }
}

现在SampleVoiceMediaBorderWidth已更新,我们只需将该值传递给视图中表示进度条的矩形的宽度。幸运的是,由于我们使用的是 MVVM,已经有一个Border元素(实际上是我们的光标),其宽度属性已连接到SampleVoiceMediaBorderWidth属性

    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>

简而言之:当MediaElement播放时,位置值被传递到ViewModel并且计算矩形值,然后将该值传回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)。然后创建了一个新的实例语音类,并调用Analyze函数,传入源(.wav)文件和包含音高值列表的目标(.txt)文件。

这个目标文件将包含一个值列表,表示音高的变化,即频率的变化。正如预期的那样,男性声音的平均值将低于女性声音。这些值将由应用程序稍后读取并在波形上显示。这就是结果的 .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 应用程序直接调用的。相反,我们实例化一个进程类并调用Start方法,同时传入源 .wav 文件和目标 .txt 文件

    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 代码一起使用。事实上,我尝试过,但它不起作用,因为Snack库本身依赖于其他 C++ 库。这是一个决定性因素,因此不幸的是无法进行移植。

提取波形

波形直接从 .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元素仅用于音高轮廓。这也可以通过一个画布元素来实现,但使用 Path 元素,您可以定义点,它会自动在它们之间绘制线

<Path x:Name="pthPitchCurve" Height="100" Width="500" Stroke="#f00" StrokeThickness="2" Data="{Binding SampleVoicePitchData}" 
HorizontalAlignment="Left" Stretch="None"></Path>

Path 元素的Data属性设置了一个Geometry指定要绘制的形状。它遵循Path Markup Syntax,这非常广泛,无法在此详细解释,但如果 Data 的值为 **“Mx0,y0 x1,y1 x2,y2 x3,y3 ... xn,yn”**,则表示我们有一个由点 {x0,y0}、{x1,y1}、{x2,y2}、{x3,y3}、…… {xn,yn} 定义的线段序列组成的几何图形。该Path属性绑定到SampleVoicePitchDataViewModel 端的属性。正如我们之前所见,此值已从 .wav 文件中提取并通过 Web 服务请求,因此我们的 Silverlight 应用程序已可用

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();
}

因此,音高轮廓显示在Patch元素中。

显示波形

波形的显示方式非常相似:我们有一个另一个Path元素用于波形

<Path x:Name="pthWave" Height="100" Width="500" Stroke="#aaa" Data="{Binding SampleVoiceWavePath}" 
HorizontalAlignment="Left" VerticalAlignment="Center" Stretch="None"></Path>

Path属性绑定到SampleVoiceWavePathViewModel 端的属性。正如我们之前所见,此值已从 .wav 文件中提取并通过 Web 服务请求

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>

Grade属性的 getter/setter 定义如下

public int Grade
{
    get
    {
        return grade;
    }
    set
    {
        grade = value;
        NotifyPropertyChanged("Grade");
    }
}

最终思考

我希望您喜欢这篇文章。虽然我希望它对您有用,但正如您所见,仍有很大的改进空间,如果您有什么要说的,请在下方留言。

历史

  • 2012-04-29:初始版本。
© . All rights reserved.