Windowsアプリにマイク録音を実装してみた。音声認識アプリ開発の第一歩!
はじめに
株式会社アドバンスト・メディアにて、ACP関連の開発を担当しています。
今回はC#を用いてWindowsアプリでマイク録音の部分を実装していきたいと思います。
完成形
開発環境
- Windows 10
- Visual Studio 2019
- WPFアプリケーション
- .Net 5.0
実装
以下の手順でアプリを実装していきたいと思います。
- AmiVoice Cloud Platform(ACP)への登録
- プロジェクトの立ち上げ
- MVVMモデルの適応
- 接続マイクから音声データ取得
- ACPを利用してWebSocketを介した音声認識
- 作成したプログラム・UIの連係
ステップ1 AmiVoice Cloud Platform(ACP)への登録
ストリーミングで音声認識をするために、まずはACPに登録します。
登録方法に関しては、下記記事をご覧ください。
ステップ2 プロジェクトの立ち上げ
Visual Studio 2019を開き、「新しいプロジェクトの作成」を押下し、「WPF」で検索を行い、「WPFアプリケーション」を選択する
プロジェクト名を入力し「次へ」を押下し、ターゲットフレームワーク「.Net 5.0」を選択
ステップ3 MVVMモデルの適応
WPFはMVVM(ModelView – View – Model)というデザインパターンを推奨しています。MVVMパターンとはソフトウェアアーキテクチャの一つで、アプリケーションの内部構造を決める指針になります。
今回はこちらのブログを参考にMVVMパターンを意識した内部構造で、アプリケーションを作成していきます。
-
- MainWindow.xaml・MainWindow.xaml.csを削除し、Views・ViewModels・Modelsフォルダを追加します。フォルダの追加はプロジェクト名の上で右クリック→追加で新しいフォルダを作成できます。画像では「ソリューション」の下にある「RecApp」がプロジェクト名になります。
- ViewsフォルダにMainViewウィンドウクラス、ViewModelsフォルダにMainViewModelクラスをそれぞれのフォルダ上で右クリック→追加で追加します。MainViewウィンドウクラスはウィンドウ(WPF)を選択すると「Window1.xaml」と「Window1.xaml.cs」が追加されます。このままではファイル名が分かりにくいためファイル名を「MainView.xaml」と「MainView.xaml.cs」に変更します。また、この時にMainView.xamlの<window>タグ内の「x:Class=”RecApp.Views. Window1“」と書かれたWindow1とMainView.xaml.csのクラス名もMainViewに変更します。
- App.xamlに書かれているStartupUriプロパティを削除し、App.xaml.csでOnStartup()メソッドを以下のコードでオーバーライドします。
App.xaml.cs
using System; using System.Collections.Generic; using System.Configuration; using System.Data; using System.Linq; using System.Threading.Tasks; using System.Windows; using RecApp.Views; using RecApp.ViewModels; namespace RecApp { /// /// Interaction logic for App.xaml /// public partial class App : Application { protected override void
OnStartup (StartupEventArgs e) { base.OnStartup (e);// ウィンドウをインスタンス化 MainView w = new MainView();// ウィンドウに対する ViewModel をインスタンス化 MainViewModel vm = new MainViewModel();// 閉じる際のイベントを設定 w.Closing = vm.Closing ;// ウィンドウに対する ViewModel をデータコンテキストに指定 w.DataContext = vm;// ウィンドウを表示 w.Show (); } } }
ステップ4 接続マイクから音声データ取得
準備
C#で接続マイクから音声データを取得するにはNAudioというライブラリを使用します。
NAudioは音声の入出力、デバイスの選択、フォーマット変換等の音声ファイルに関する処理を手軽に扱うことができるライブラリになります。NAudioの実装はGitHub上にも公開されていますので、気になる方はコチラをご覧ください。
はじめに、NAudioを使えるようにするため、NuGetからライブラリをダウンロードします。ツール > NuGetパッケージマネージャー > ソリューションのNuGetパッケージの管理 > 参照で「NAudio」を検索します。NAudioのバージョンを1.10.0に変更し、対象のプロジェクトにチェックを入れ、インストールします。
※1.10.0でも十分な機能があり、SoundTouchという音声ファイルを再生する際に再生速度や音程をリアルタイムに変更できるライブラリとの互換性があるため、最新バージョンではなく1.10.0を使用しています。
(WPFにはMediaElementという動画・音声再生するタグが存在し、こちらでも再生速度を変更できますが、再生速度を変更した際に無音時間が一定時間存在する場合があります。しかし、SoundTouchを用いた場合では無音時間が存在しないため、違和感のない再生速度変更が可能となります。)
次にNAudio関連の操作を行うクラスを作成するために、Modelsフォルダ内に新しくクラスファイルを作成します(以下、Audioクラスとします)。NAudioを使った処理は全てAudioクラスに記述していきます。
NAudioを用いて音声を録音するにはWaveInクラスとWaveInEventクラスの2種類存在します。両クラス共に
- 録音に使用するマイクデバイスの設定
- 録音した音声についての処理
- 録音終了時の処理
等を行えるクラスになります。WaveInクラスとWaveInEventクラスそれぞれの内部的な処理は異なりますが、できることは同じになります。では、2つのクラスをどのように使い分けるのかというと
WaveIn : GUIアプリケーション
WaveInEvent : コンソールアプリケーション(GUIアプリケーションでも使用可)
のように使用用途が分かれています。また、WaveInクラスではWindows Messagesを使用していますが、WaveInEventクラスではWindows Messagesを使用しない作りになっています。今回はGUIアプリケーションを作成しますので、WaveInクラスを使用して録音部分の処理を記述していこうと思います。
※WaveInとWaveInEventクラスで使用できるメソッド等は同じですので、WaveInEventクラスを使用して今回の作成するアプリを作成するということであれば、WaveInの部分をWaveInEventに変更するだけで録音処理に関する部分は動作します。
録音処理
録音する処理のコードは以下になります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NAudio.Wave;
namespace RecApp.Models
{
/// <summary>
/// 録音状態を表す列挙型
/// <summary>
public enum RecordingState
{
Recording,
Stop,
Error
}
/// <summary>
/// NAudioを操作するクラス
/// <summary>
class Audio
{
// 録音を行うクラス
private WaveIn m_waveIn = null;
// 録音時のフォーマットを設定するクラス
private WaveFormat m_recordinFormat = null;
// 録音状態を管理する変数
public RecordingState m_recordingState { get; private set; }
public Audio ()
{
// サンプリング周波数 16000Hz 1ch 16bit PCMのフォーマット作成
m_recordinFormat = new WaveFormat (16000, 1);
}
/// <summary>
/// 録音開始
/// <summary>
public void RecordingStart ()
{
// 初期化&録音時のフォーマットを設定
m_waveIn = new WaveIn ();
m_waveIn.WaveFormat = m_recordinFormat;
// 録音デバイス設定
// デフォルトデバイスで録音
m_waveIn.DeviceNumber = 0;
// 録音中に発生するイベント
m_waveIn.DataAvailable += (_, ee) =>
{
try
{
// エラーが起きていないか
if (m_recordingState == RecordingState.Recording)
{
// 録音した音声データを処理する
}
}
catch (Exception e)
{
// エラー確認
if (m_recordingState == RecordingState.Recording)
{
// 二重に停止処理が起きないようにする
m_recordingState = RecordingState.Error;
// 録音停止
RecordingStop();
}
}
}
// 録音終了時のイベント
m_waveIn.RecordingStopped += (_, __) =>
{
// 録音状態が録音中・エラーの場合はインスタンスを解放する
if (m_recordingState != RecordingState.Stop)
{
// 録音状態変更
m_recordingState = RecordingState.Stop;
// WaveInインスタンス解放
m_waveIn.Dispose();
m_waveIn = null;
// 録音終了時に行う処理
}
}
// 録音開始
m_waveIn.StartRecording();
m_recordingState = RecordingState.Recording;
}
/// <summary>
/// 録音停止
/// <summary>
public void RecordingStop ()
{
// 録音停止
m_waveIn?.RecordingStop();
}
}
}
一つずつ処理を見ていきます。まず始めにコンストラクタで行われている
// サンプリング周波数 16000Hz 1ch 16bit PCMのフォーマット作成
m_recordinFormat = new WaveFormat (16000, 1);
では、録音する際の音声フォーマットを指定しています。今回はサンプリング周波数とチャンネル数のみ指定していますが、bit数も設定出来ます。
録音開始時に行う処理がRecordingStartメソッドに書かれています。ここに書かれている
// 初期化&録音時のフォーマットを設定
m_waveIn = new WaveIn ();
m_waveIn.WaveFormat = m_recordinFormat;
// 録音デバイス設定
// デフォルトデバイスで録音
m_waveIn.DeviceNumber = 0;
では録音するために必要な録音デバイスやフォーマットを指定しています。また、録音のたびに新しいWaveInクラスをインスタンス化しています。これはWaveInクラスは同じインスタンスを使用した際に、内部で使用されているWin32 APIの1つである「waveInUnprepareHeader」で「WAVERR_STILLPLAYING」のWindows Messageが送られ、録音開始と同時に録音停止処理が走ることがあり(何らかのエラーが発生した際には録音停止処理が動作する)、これを回避するために毎回新しいインスタンスを作成しています。
DataAvailableに録音した音声データに対して行う処理を記述できます。
※DataAvailableはWaveIn内部のバッファがキューで満たされた際に発火するイベントになります。デフォルトでは100msごとにこのイベントが発火します。
// 録音中に発生するイベント
m_waveIn.DataAvailable += (_, ee) =>
{
try
{
// エラーが起きていないか
if (m_recordingState == RecordingState.Recording)
{
// 録音した音声データを処理する
}
}
catch (Exception e)
{
// エラー確認
if (m_recordingState == RecordingState.Recording)
{
// 二重に停止処理が起きないようにする
m_recordingState = RecordingState.Error;
// 録音停止
RecordingStop();
}
}
}
録音したデータに何らかの処理を行っている最中にエラーが発生した場合に、録音を停止するためにtry-catch文を使用しています。
RecordingStoppedに録音を停止した際の処理を記述できます。
// 録音終了時のイベント
m_waveIn.RecordingStopped += (_, __) =>
{
// 録音状態が録音中・エラーの場合はインスタンスを解放する
if (m_recordingState != RecordingState.Stop)
{
// 録音状態変更
m_recordingState = RecordingState.Stop;
// WaveInインスタンス解放
m_waveIn.Dispose();
m_waveIn = null;
// 録音終了時に行う処理
}
}
RecordingStoppedは一番最後に行われる処理になりますので、ここでインスタンスを解放します。
※RecordingStopメソッドを使用した後はWaveInクラスの内部ではフラグの変更(録音停止)→DataAvailable(未処理のもの)→RecordingStoppedの順に処理が行われます。
「WaveIn」クラスのStartRecordingメソッドで録音が開始され、RecordingStopメソッドで録音を停止します。
// 録音開始
m_waveIn.StartRecording();
m_recordingState = RecordingState.Recording;
/// <summary>
/// 録音停止
/// <summary>
public void RecordingStop ()
{
// 録音停止
m_waveIn?.RecordingStop();
}
ステップ5 ACPを利用してWebSocketを介した音声認識
準備
ACPを利用してWebSocketを介した音声認識のWebSocket部分につきましてはこちらの記事に書かれておりますので、今回はACPのホームページのサンプルプログラムのWebSocket部分をそのままアプリに組み込みたいと思います。
AmiVoice Cloud Platform-Tech Blog
はじめに、コチラよりサンプルプログラムをダウンロードします。
サンプルプログラムの「sample_1.1.8/Wrp/cs/src」にある「com」フォルダーをModelsフォルダー内にコピーします。
※「com」フォルダ内の「Wrp」ファイルにWebSocketに関する処理が記述されています。そのため、自分でWebSocketに関する処理も記述する場合には参考にしてください。
次に、WebSocketを介した音声認識結果について記述するクラスファイルをModelsフォルダに新たに追加します(以下、WrpSimpleクラスとする)。WebSocketを介して返ってきた音声認識結果の処理は全てこのWrpSimpleクラスに記述していきます。
※今回のアプリでは返ってきた音声認識結果をパースし、UI表示しか行いませんが、他にも様々なイベントが存在しています(例えば、発話区間を検出した際に発火するイベント等)。作成したいアプリに合わせて各イベントを使用してください。
認識結果処理
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace RecApp.Models
{
/// <summary>
/// 認識結果を扱うクラス
/// </summary>
class WrpSimple : com.amivoice.wrp.WrpListener
{
public void utteranceStarted(int startTime) { }
public void utteranceEnded(int endTime) { }
public void resultCreated() { }
public void resultUpdated(string result) { }
public void resultFinalized(string result)
{
string text = TextParse(result);
}
public void eventNotified(int eventId, string eventMessage) { }
public void TRACE(string message) { }
/// <summary>
/// 認識結果を変換
/// </summary>
/// <param name="result">JSON形式の認識結果文字列</param>
/// <returns>認識結果
private string TextParse(string result)
{
int index = result.LastIndexOf(",\"text\":\"");
if (index == -1)
{
return null;
}
index += 9;
int resultLength = result.Length;
StringBuilder buffer = new StringBuilder();
int c = (index >= resultLength) ? 0 : result[index++];
while (c != 0)
{
if (c == '"')
{
break;
}
if (c == '\\ ')
{
c = (index >= resultLength) ? 0 : result[index++];
if (c == 0)
{
return null;
}
if (c == '"' || c == '\\' || c == '/')
{
buffer.Append((char)c);
}
else
if (c == 'b' || c == 'f' || c == 'n' || c == 'r' || c == 't')
{
}
else
if (c == 'u')
{
int c0 = (index >= resultLength) ? 0 : result[index++];
int c1 = (index >= resultLength) ? 0 : result[index++];
int c2 = (index >= resultLength) ? 0 : result[index++];
int c3 = (index >= resultLength) ? 0 : result[index++];
if (c0 >= '0' && c0 <= '9') { c0 -= '0'; } else if (c0 >= 'A' && c0 <= 'F') { c0 -= 'A' - 10; } else if (c0 >= 'a' && c0 <= 'f') { c0 -= 'a' - 10; } else { c0 = -1; }
if (c1 >= '0' && c1 <= '9') { c1 -= '0'; } else if (c1 >= 'A' && c1 <= 'F') { c1 -= 'A' - 10; } else if (c1 >= 'a' && c1 <= 'f') { c1 -= 'a' - 10; } else { c1 = -1; }
if (c2 >= '0' && c2 <= '9') { c2 -= '0'; } else if (c2 >= 'A' && c2 <= 'F') { c2 -= 'A' - 10; } else if (c2 >= 'a' && c2 <= 'f') { c2 -= 'a' - 10; } else { c2 = -1; }
if (c3 >= '0' && c3 <= '9') { c3 -= '0'; } else if (c3 >= 'A' && c3 <= 'F') { c3 -= 'A' - 10; } else if (c3 >= 'a' && c3 <= 'f') { c3 -= 'a' - 10; } else { c3 = -1; }
if (c0 == -1 || c1 == -1 || c2 == -1 || c3 == -1)
{
return null;
}
buffer.Append((char)((c0 << 12) | (c1 << 8) | (c2 << 4) | c3));
}
else
{
return null;
}
}
else
{
buffer.Append((char)c);
}
c = (index >= resultLength) ? 0 : result[index++];
}
return buffer.ToString();
}
}
}
WrpSimpleクラスに「com.amivoice.wrp」に存在する、WrpListenerインターフェースを継承させます。
/// <summary>
/// 認識結果を扱うクラス
/// </summary>
class WrpSimple : com.amivoice.wrp.WrpListener
インターフェースを継承したことにより以下の7つを実装しなければなりません。
public void utteranceStarted(int startTime) { }
public void utteranceEnded(int endTime) { }
public void resultCreated() { }
public void resultUpdated(string result) { }
public void resultFinalized(string result)
{
string text = TextParse(result);
}
public void eventNotified(int eventId, string eventMessage) { }
public void TRACE(string message) { }
今回は音声認識が終了した際にUI表示させたいので、resultFinalizedしか使用しません。resultFinalizedは特定の発話区間の音声認識結果が確定した際に発火するイベントになります。音声認識結果はJSON形式の文字列になっています。そのため、パースを行い、必要な部分だけ取り出す必要があります。
「TextParse」メソッドでJSON形式の文字列から認識結果のみを取り出しています。
/// <summary>
/// 認識結果を変換
/// </summary>
/// <param name="result">JSON形式の認識結果文字列</param>
/// <returns>認識結果
private string TextParse(string result)
{
int index = result.LastIndexOf(",\"text\":\"");
if (index == -1)
{
return null;
}
index += 9;
int resultLength = result.Length;
StringBuilder buffer = new StringBuilder();
int c = (index >= resultLength) ? 0 : result[index++];
while (c != 0)
{
if (c == '"')
{
break;
}
if (c == '\\ ')
{
c = (index >= resultLength) ? 0 : result[index++];
if (c == 0)
{
return null;
}
if (c == '"' || c == '\\' || c == '/')
{
buffer.Append((char)c);
}
else
if (c == 'b' || c == 'f' || c == 'n' || c == 'r' || c == 't')
{
}
else
if (c == 'u')
{
int c0 = (index >= resultLength) ? 0 : result[index++];
int c1 = (index >= resultLength) ? 0 : result[index++];
int c2 = (index >= resultLength) ? 0 : result[index++];
int c3 = (index >= resultLength) ? 0 : result[index++];
if (c0 >= '0' && c0 <= '9') { c0 -= '0'; } else if (c0 >= 'A' && c0 <= 'F') { c0 -= 'A' - 10; } else if (c0 >= 'a' && c0 <= 'f') { c0 -= 'a' - 10; } else { c0 = -1; }
if (c1 >= '0' && c1 <= '9') { c1 -= '0'; } else if (c1 >= 'A' && c1 <= 'F') { c1 -= 'A' - 10; } else if (c1 >= 'a' && c1 <= 'f') { c1 -= 'a' - 10; } else { c1 = -1; }
if (c2 >= '0' && c2 <= '9') { c2 -= '0'; } else if (c2 >= 'A' && c2 <= 'F') { c2 -= 'A' - 10; } else if (c2 >= 'a' && c2 <= 'f') { c2 -= 'a' - 10; } else { c2 = -1; }
if (c3 >= '0' && c3 <= '9') { c3 -= '0'; } else if (c3 >= 'A' && c3 <= 'F') { c3 -= 'A' - 10; } else if (c3 >= 'a' && c3 <= 'f') { c3 -= 'a' - 10; } else { c3 = -1; }
if (c0 == -1 || c1 == -1 || c2 == -1 || c3 == -1)
{
return null;
}
buffer.Append((char)((c0 << 12) | (c1 << 8) | (c2 << 4) | c3));
}
else
{
return null;
}
}
else
{
buffer.Append((char)c);
}
c = (index >= resultLength) ? 0 : result[index++];
}
return buffer.ToString();
}
こちらの処理はサンプルプログラムの「sample_1.1.8/Wrp/cs」にある「WrpTester.cs」内のtext_メソッドをそのままコピペしたものになります。
ステップ6 作成したプログラム・UIの連係
準備
ステップ4・5で作成した音声録音・音声認識結果処理、サンプルプログラムから移植したクラス、UIをそれぞれ紐づけていきたいと思います。
初めてに、UIを作成していきます。今回はUIデザインについては言及しません。そのため、WPFのTextBoxとButtonがあればアプリとして動作します。UIを考えたくない方は以下のxamlコードを「MainView.xaml」にコピペしてください。
※コードに記載されているRecAppの部分を全て作成したプロジェクト名に変更する必要があります。
<Window x:Class="RecApp.Views.MainView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:RecApp.Views.Behavior"
mc:Ignorable="d"
Title="録音アプリ" Height="500" Width="300">
<Window.Resources>
<!-- Visibilityをbool値で変換出来るようにするためのコンバータ -->
<BooleanToVisibilityConverter x:Key="BoolVisibilityConverter"/>
<!-- 点滅アニメーション -->
<Storyboard x:Key="BlinkStory">
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Opacity)" RepeatBehavior="Forever" AutoReverse="True">
<LinearDoubleKeyFrame KeyTime="0" Value="1"/>
<LinearDoubleKeyFrame KeyTime="0:0:1" Value="0"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
<!-- 点滅のためのスタイルの作成 -->
<Style x:Key="BlinkingStyle" TargetType="TextBlock">
<Style.Triggers>
<Trigger Property="IsVisible" Value="True">
<Trigger.EnterActions>
<BeginStoryboard x:Name="BlinkingStoryboard1" Storyboard="{StaticResource BlinkStory}"/>
</Trigger.EnterActions>
<Trigger.ExitActions>
<StopStoryboard BeginStoryboardName="BlinkingStoryboard1"/>
</Trigger.ExitActions>
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="40"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Background="LightGray" Panel.ZIndex="2" Opacity="0.5"
Visibility="{Binding BlinkStory, Converter={StaticResource BoolVisibilityConverter}}">
<TextBlock Foreground="Black" FontSize="60" TextAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding BlinkStory, Converter={StaticResource BoolVisibilityConverter}}"
Style="{StaticResource BlinkingStyle}">
録音中
</TextBlock>
</Grid>
<TextBox Grid.Row="0" Text="{Binding RecognitionResultText}" Panel.ZIndex="1"
VerticalScrollBarVisibility="Visible" TextWrapping="Wrap"
local:ScrollToEndBehavior.AutoScrollToEnd="True"/>
<Button Grid.Row="1" Content="{Binding ButtonContent}"
Command="{Binding RecordingCommand}"/>
</Grid>
</Window>
ここでは
- Binding
- ScrollToEndBehavior.AutoScrollToEnd
- Storyboard
- Converter
の4点ついて簡単に説明します。
・Binding
「Binding ***」では***のデータ(プロパティ)にバインディングしています。こうすることで、ViewModel側で要素の値を変更すると自動的にUIに反映されるようになります。[1]
・ScrollToEndBehavior.AutoScrollToEnd
こちらはMVVMに準拠してアプリを作成すると、コードビハインド(MainView.xaml.csにコードを記述すること)を使用しないため、Viewの状態変化により実行される処理を記述することが困難になります。その代替手段としてBehaviorクラスがあります。[2]今回作成するアプリでは末尾に認識結果を追加していきます。そのため、常に最新の認識結果を表示させるには認識結果が出るたびに末尾までスクロールする必要があります。そこで、認識結果が表示されるたびに末尾までスクロールするためにBehaviorクラスを使用します。Behaviorクラスの実装内容は後ほど記述します。
・Storyboard
StoryboardはWPFでアニメーションを作成することができるものになります。ここでは、録音中にテキストボックスを操作出来ないようにするかつ録音中であることが分かるようにするためにBlinkするアニメーションを作成しています。Storyboardタグで囲まれた部分でアニメーションの内容を決めています。今回はOpacityプロパティを変更させ、Blinkさせています。作成したアニメーションを発火するトリガーを設定している部分がStoryboardタグの下に書かれているStyleタグになります。今回はこのStyleを使用している要素が画面に見えている時にアニメーションするようにしています。こちらの記事がStoryboardについて詳しく解説しております。Storyboardについてさらに知りたいという方は参考にしてください。
・Converter
Converterとは文字通り、変換するものことを言います。こちらの記事にも記載されているように、チェックボックスのチェックされているかどうかを表すプロパティであるIsCheckedプロパティ(valueはbool型)をConverterを使用することで文字列で状態を表すことが出来たりします。今回のアプリでは録音中はVisibilityプロパティを「Visibility」に、それ以外では「Hidden」or「Collapse」にしようとしています。Visibilityプロパティのvalueはbool型ではなく、列挙型が用いられており、bool型で画面に表示する・しないが制御が出来ません。そのため、Converterを用いてtrueの時「Visibility」falseの時「Collapse」となるようにしています。
※BooleanToVisibilityConverter クラスというものが標準で実装されています。そのため、こちらを使用することで新しくConverterクラスを作成することなく、行いたい処理を実装出来ます。
ViewModelについて
ViewModelクラスではデータバインディングしたデータやModelをViewと繋げる役割を担います。View要素にデータバインディングするためには、「INotifyPropertyChanged・ICommand」を使用して、View側に値が変更された事を通知します。しかし、これらをそのまま使用するのはなかなか骨が折れます。そのため、手軽に使用するために、NuGetから「Prism.Wpf」をインストールします。PrismとはMVVMフレームワークの一つになります。「Prism」を使うことで「INotifyPropertyChanged・ICommand」を用いたデータバインディング関連の処理を短く簡単に記述することが可能になります。
「MainViewModel.cs」の全体のコードは以下のようになります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.ComponentModel;
using com.amivoice.wrp;
using RecApp.Models;
using Prism.Commands;
using Prism.Mvvm;
namespace RecApp.ViewModels
{
// 録音状態を管理するクラス(ボタン文言変更用)
public class RecordingStateNotifyEventArgs
{
public RecordingState state { get; set; }
}
// テキストボックスに文字を表示させるためのクラス
public class ResultNotifyEventArgs
{
public string result { get; set; }
}
/// <summary>
/// MainView ウィンドウに対するデータコンテキストを表します。
/// </summary>
internal class MainViewModel : BindableBase
{
/// <summary>
/// アプリを切った際のイベント WebSocketの接続を切る
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
internal void Closing(object sender, CancelEventArgs e)
{
// WebSocketを接続したままアプリを落とした場合に
wrp?.disconnect();
}
/// <summary>
/// テキストボックスに書かれている内容
/// </summary>
private string _text;
public string RecognitionResultText
{
get { return this._text; }
set
{
SetProperty(ref _text, value);
}
}
/// <summary>
/// 録音ボタンに表示する文言
/// </summary>
private string _buttonContent = "録音を開始する";
public string ButtonContent
{
get { return this._buttonContent; }
set
{
SetProperty(ref _buttonContent, value);
}
}
/// <summary>
/// 録音中に表示するアニメーションを制御する
/// </summary>
private bool __isBlinkVisibility = false;
public bool IsBlinkVisibility
{
get { return this.__isBlinkVisibility; }
set
{
SetProperty(ref __isBlinkVisibility, value);
}
}
/// <summary>
/// 録音コマンドを取得します。
/// </summary>
public DelegateCommand RecordingCommand { get; }
// WebSocketに関する変数
Wrp wrp;
private Audio m_audio = null;
public MainViewModel()
{
// クリックイベントを設定
RecordingCommand = new DelegateCommand(ButtonClick );
// WebSocket 音声認識サーバイベントリスナの作成
WrpSimple listener = new WrpSimple();
// WebSocket 音声認識サーバの初期化
wrp = Wrp.construct();
wrp.setListener(listener);
// 接続するサーバー名
wrp.setServerURL("wss://acp-api.amivoice.com/v1/");
// 音声フォーマット
wrp.setCodec("LSB16K");
// 使用する辞書
wrp.setGrammarFileNames("-a-general");
// AppKey
wrp.setAuthorization("Your AppKey");
// 認識結果をテキストボックスへ書き込むためのイベント
listener.ResultNotifyHandler += SetTextNotifyHandler;
// NAudioを扱うクラスインスタンス化
m_audio = new Audio(wrp);
// 録音状態に変化があった際にボタンの文言を変更するためのイベント
// 通知を受ける関数の登録
m_audio.RecordingStateChanged += RecordingStateChanged;
// WebSocketの接続状態をテキストボックスへ書き込むためのイベント
m_audio.ResultNotifyHandler += SetTextNotifyHandler;
}
private void RecordingStateChanged(object sender, RecordingStateNotifyEventArgs e)
{
switch (e.state)
{
case RecordingState.Recording:
{
ButtonContent = "録音を停止する";
IsBlinkVisibility = true;
break;
}
case RecordingState.Stop:
case RecordingState.Error:
{
ButtonContent = "録音を開始する";
IsBlinkVisibility = false;
break;
}
}
}
private void SetTextNotifyHandler(object sender, ResultNotifyEventArgs args)
{
RecognitionResultText += args.result + "\r\n";
}
/// <summary>
/// ボタンをクリックした際の動作
/// </summary>
private void ButtonClick()
{
if (m_audio.m_recordingState != RecordingState.Stop)
{
// 停止する
m_audio.RecordingStop();
}
else
{
RecognitionResultText = "";
// 録音する
m_audio.RecordingStart();
}
}
}
}
上から順にコードを追っていきます。
Modelで生成したデータをViewに表示させるにはViewModelに一度データを渡す必要があります。Modelから受け取ったデータをViewに渡すイベントの為にイベント変数を作成します。
// 録音状態を管理するクラス(ボタン文言変更用)
public class RecordingStateNotifyEventArgs
{
public RecordingState state { get; set; }
}
// テキストボックスに文字を表示させるためのクラス
public class ResultNotifyEventArgs
{
public string result { get; set; }
}
今回は録音状態・文字列しかやり取りしないため、録音状態・文字列のみを持つクラスを作成します。
/// <summary>
/// MainView ウィンドウに対するデータコンテキストを表します。
/// </summary>
internal class MainViewModel : BindableBase
「BindableBase」は「INotifyPropertyChanged」を実装する際のヘルパークラスになります。こちらを継承することで、View側に値の変更を簡単に通知させることが可能なります。
/// <summary>
/// アプリを切った際のイベント WebSocketの接続を切る
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
internal void Closing(object sender, CancelEventArgs e)
{
// WebSocketを接続したままアプリを落とした場合に接続を切る
wrp?.disconnect();
}
では、WebSocketと接続したままアプリを落とした場合に、接続を切断するための処理になります。「App.xaml.cs」の「w.Closing += vm.Closing;」で閉じる際の処理をMainViewに追加しています。
※録音中にアプリを落とすとWebSocketと接続したままアプリを落とすことになります。
次にButton・TextBoxにバインディングするデータ・アニメーションを発火させるトリガーについては
/// <summary>
/// テキストボックスに書かれている内容
/// </summary>
private string _text;
public string RecognitionResultText
{
get { return this._text; }
set
{
SetProperty(ref _text, value);
}
}
/// <summary>
/// 録音ボタンに表示する文言
/// </summary>
private string _buttonContent = "録音を開始する";
public string ButtonContent
{
get { return this._buttonContent; }
set
{
SetProperty(ref _buttonContent, value);
}
}
/// <summary>
/// 録音中に表示するアニメーションを制御する
/// </summary>
private bool _isBlinkVisibility = false;
public bool IsBlinkVisibility
{
get { return this._isBlinkVisibility; }
set
{
SetProperty(ref _isBlinkVisibility, value);
}
}
のように記述します。SetPropertyを用いることで値が変更された場合にViewに値が変更された通知を送ります。SetPropartyの詳しい処理内容を知りたい方はGithubにコードが公開されていますのでそちらを参考にしてください。
録音ボタンを押下した際に、録音開始・停止を行うイベントはDelegateCommandを用いてViewに渡します。
/// <summary>
/// 録音コマンドを取得します。
/// </summary>
public DelegateCommand RecordingCommand { get; }
// クリックイベントを設定
RecordingCommand = new DelegateCommand(ButtonClick );
のようにDelegateCommandにボタンを押下した際に行いたい処理を引数として渡します。
WebSocketについては
// WebSocketに関する変数
Wrp wrp;
// WebSocket 音声認識サーバイベントリスナの作成
WrpSimple listener = new WrpSimple();
// WebSocket 音声認識サーバの初期化
wrp = Wrp.construct();
wrp.setListener(listener);
// 接続するサーバ名
wrp.setServerURL("wss://acp-api.amivoice.com/v1/");
// 音声フォーマット
wrp.setCodec("LSB16K");
// 使用する辞書
wrp.setGrammarFileNames("-a-general");
// AppKey
wrp.setAuthorization("Your AppKey");
// 認識結果をテキストボックスへ書き込むためのイベント
listener.ResultNotifyHandler += SetTextNotifyHandler;
のようになります。接続するサーバ名等の上述した別の記事で記載されている部分については今回は触れません。ステップ5で作成したWrpSimpleクラスをWrpクラスのリスナに設定します。これにより、作成した音声認識結果イベントを発火させることが可能になります。また、ViewModelにデータを渡すためにWrpSimpleクラスを変更する必要があります。そのため、ViewModelにデータを渡すためのイベントを新たに定義しています。定義した内容については後ほど記述します。
Audioクラスは
private Audio m_audio = null;
// NAudioを扱うクラスインスタンス化
m_audio = new Audio(wrp);
// 録音状態に変化があった際にボタンの文言を変更するためのイベント
// 通知を受ける関数の登録
m_audio.RecordingStateChanged += RecordingStateChanged;
// WebSocketの接続状態をテキストボックスへ書き込むためのイベント
m_audio.ResultNotifyHandler += SetTextNotifyHandler;
のようになります。ACPサーバに音声を送信するためにWrpクラスが必要になります。そのため、AudioクラスもACPサーバ・ViewModelとの接続するために、クラスを変更する必要があります。変更点については後ほど記述します。ここでは、「ACPサーバ・ViewModelとの接続のために、イベントを定義し、処理を追加している」という認識をして頂ければ十分です。
Behaviorクラスについて
始めにBehaviorクラスを書くためのファイルを作成します。Viewsフォルダに新しくBehaviorファルダを作成します。その中にクラスファイルを作成し、ファイル名を「ScrollToEndBehavior.cs」にします(以下、ScrollToEndBehaviorクラスとする)。新しい認識結果が表示された際に、自動で一番下までスクロールバーが異動する処理をScrollToEndBehaviorクラスに記述していきます。
こちらの記事の添付ビヘイビアを参考に作成したScrollToEndBehaviorクラスは以下のようになります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
namespace RecApp.Views.Behavior
{
class ScrollToEndBehavior
{
/// <summary>
/// 複数行のテキストを扱う
/// テキスト追加時に最終行が表示されるようにする
/// </summary>
public static readonly DependencyProperty AutoScrollToEndProperty =
DependencyProperty.RegisterAttached(
"AutoScrollToEnd", // プロパティ名を指定
typeof(bool), // プロパティの型を指定
typeof(ScrollToEndBehavior), // プロパティを所有する型を指定
new FrameworkPropertyMetadata(false, IsTextChanged) // メタデータを指定
);
[AttachedPropertyBrowsableForType(typeof(TextBox))] // xaml側のプロパティで表示する型指定
// Get Setを記述する必要があるため記述
public static bool GetAutoScrollToEnd(DependencyObject obj)
{
return (bool)obj.GetValue(AutoScrollToEndProperty);
}
public static void SetAutoScrollToEnd(DependencyObject obj, bool value)
{
obj.SetValue(AutoScrollToEndProperty, value);
}
// プロパティ
private static void IsTextChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
TextBox textBox = (TextBox)sender;
if (textBox == null) return;
// イベントを登録・削除
textBox.TextChanged -= OnTextChanged;
bool newValue = (bool)e.NewValue;
if (newValue == true)
{
textBox.TextChanged += OnTextChanged;
}
}
private static void OnTextChanged(object sender, TextChangedEventArgs e)
{
TextBox textBox = (TextBox)sender;
if (textBox == null) return;
if (string.IsNullOrEmpty(textBox.Text)) return;
if (textBox.IsKeyboardFocused == false) textBox.ScrollToEnd();
}
}
}
添付ビヘイビアについてはこちらの記事に詳しく書かれいるので、付与したイベントであるOnTextChangedについてのみ説明します。
OnTextChangedではTextBox内のテキストを変更すれば、末尾までスクロールを行うようにしています。ただし、TextBoxがキーボードフォーカスを持っている場合はスクロールしません。これは認識結果を編集する際に1文字変更するたびに末尾までスクロールされると編集作業が大変になります。そのため、録音中のみスクロールするようにしています。
WPFのフォーカスには2つの概念が存在します。
- キーボードフォーカス
- 論理フォーカス
の2種類のフォーカスが存在します。それぞれの違いは
- キーボードフォーカス:現在キーボード入力を受け取っている要素であり、デスクトップ全体で1つしか存在しない
- 論理フォーカス:任意のフォーカス範囲内に1つしか存在しない。複数存在することがある
詳細を知りたい方は公式のDocを参考にしてください。
録音クラスとWebSocket処理の連係
音声認識するには録音した音声データをACPサーバに渡す必要があります。そのため、Audioクラスを変更します。次節のUIと連係で再度Audioクラスを変更するため、ここではACPサーバと接続し音声認識するために使用するメソッドについて説明します。
using com.amivoice.wrp;
// WebSocket関連のクラス
private Wrp m_wrp = null;
public Audio (Wrp wrp)
{
// サンプリング周波数 16000Hz 1ch 16bit PCMのフォーマット作成
m_recordinFormat = new WaveFormat (16000, 1);
m_wrp = wrp;
}
始めに、Wrpクラスを使用するために、上部のusingに「com.amivoice.wrp」を追加し、Audioクラス内でオブジェクトを持つために変数を宣言します。宣言したオブジェクトにAudioクラスをインスタンス化する際に値を渡します。
// 音声認識サーバへの接続
m_wrp.connect()
// 音声認識サーバへの音声データの送信開始
m_wrp.feedDataResume()
// 音声認識サーバへの音声データの送信
m_wrp.feedData(ee.BUffer, 0 , ee.BytesRecorded)
// 音声認識サーバへの音声データの送信完了
m_wrp.feedDataPause()
// 音声認識サーバから切断
m_wrp.disconnect()
ACPサーバに音声データを送る手順は
- サーバへ接続
- 音声データ送信開始メッセージを送信
- 音声データをサーバに送信
- 送信終了メッセージを送信
- サーバから切断
の手順を踏むことでサーバに音声データを送信し、音声認識を行うことができます。これに対応するWrpのメソッドは
- connect
- feedDataResume
- feedData
- feedDataPause
- disconnect
になります。これを順に使用することで、音声認識を行うことが出来ます。feedDataメソッドでACPサーバへ音声データを送信しているので、このメソッドをWaveInクラスのDataAvailableの中で実行する必要があります。
録音クラス・認識結果処理クラスとUIの連係
最終的なAudioクラスは次のようになります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NAudio.Wave;
using com.amivoice.wrp;
using RecApp.ViewModels;
namespace RecApp.Models
{
/// <summary>
/// 録音状態を表す列挙型
/// </summary>
public enum RecordingState
{
Recording,
Stop,
Error
}
/// <summary>
/// NAudioを操作するクラス
/// </summary>
class Audio
{
// 録音を行うクラス
private WaveIn m_waveIn = null;
// 録音時のフォーマットを設定するクラス
private WaveFormat m_recordinFormat = null;
// WebSocket関連のクラス
private Wrp m_wrp = null;
// 接続情報をテキストボックスへ書きこむためのイベント
public event EventHandler<ResultNotifyEventArgs> ResultNotifyHandler;
// 録音状態を管理する変数
private RecordingState _recordingState = RecordingState.Stop;
// ここの値を変更する際にイベントを発火
public RecordingState m_recordingState
{
get
{
return _recordingState;
}
private set
{
// 状態が変化しているか
if (this._recordingState == value) return;
this._recordingState = value;
// データが変更されたときに通知することをここで集中管理
OnRecordingStateChanged(value);
}
}
// 録音状態状態の変更を通知するevent
public event EventHandler<RecordingStateNotifyEventArgs> RecordingStateChanged;
public Audio(Wrp wrp)
{
// サンプリング周波数 16000Hz 1ch 16bit PCMのフォーマット作成
m_recordinFormat = new WaveFormat(16000, 1);
m_wrp = wrp;
}
/// <summary>
/// 録音開始
/// </summary>
public void RecordingStart()
{
// 音声認識サーバへの接続
if (m_wrp.connect() == false)
{
SetText(m_wrp.getLastMessage());
SetText("WebSocket 音声認識サーバへの接続に失敗しました。");
return;
}
SetText("WebSocket 音声認識サーバへの接続に成功しました。");
// 初期化&録音時のフォーマットを設定
m_waveIn = new WaveIn();
m_waveIn.WaveFormat = m_recordinFormat;
// 録音デバイス設定
// デフォルトデバイスで録音
m_waveIn.DeviceNumber = 0;
// 録音中に発生するイベント
m_waveIn.DataAvailable += (_, ee) =>
{
try
{
// エラーが起きていないか
if (m_recordingState == RecordingState.Recording)
{
// 音声認識サーバへの音声データの送信
if (m_wrp.feedData(ee.Buffer, 0, ee.BytesRecorded) == false)
{
m_recordingState = RecordingState.Error;
SetText(m_wrp.getLastMessage());
SetText("WebSocket 音声認識サーバへの音声データの送信に失敗しました。");
// 録音停止
RecordingStop();
}
}
}
catch (Exception e)
{
SetText("エラー発生");
SetText(e.Message);
// エラー確認
if (m_recordingState == RecordingState.Recording)
{
// 二重に停止処理が起きないようにする
m_recordingState = RecordingState.Error;
// 録音停止
RecordingStop();
}
}
};
// 録音終了時のイベント
m_waveIn.RecordingStopped += (_,__) =>
{
SetText("録音終了");
// 録音状態が録音中・エラーの場合はインスタンスを解放する
if (m_recordingState != RecordingState.Stop)
{
// 録音状態変更
m_recordingState = RecordingState.Stop;
// WaveInインスタンス解放
m_waveIn.Dispose();
m_waveIn = null;
// 音声認識サーバへの音声データの送信完了
if (m_wrp.feedDataPause() == false)
{
SetText(m_wrp.getLastMessage());
SetText("WebSocket 音声認識サーバへの音声データの送信完了に失敗しました。");
}
// 音声認識サーバから切断
m_wrp.disconnect();
SetText("WebSocket 音声認識サーバへの接続を切断しました。");
}
};
// 音声認識サーバへの音声データの送信開始
if (m_wrp.feedDataResume() == false)
{
m_wrp.disconnect();
SetText(m_wrp.getLastMessage());
SetText("WebSocket 音声認識サーバへの音声データの送信開始に失敗しました。");
return;
}
// 録音開始
m_waveIn.StartRecording();
m_recordingState = RecordingState.Recording;
}
/// <summary>
/// 録音停止
/// </summary>
public void RecordingStop()
{
// 録音停止
m_waveIn?.StopRecording();
}
/// <summary>
/// 接続状態をテキストボックスへ書き込む
/// </summary>
/// <param name="result">書き込む内容</param
private void SetText(string result)
{
if (ResultNotifyHandler != null)
{
var args = new ResultNotifyEventArgs() { result = result };
ResultNotifyHandler(this, args);
}
}
/// <summary>
/// 録音状態変更イベントを発火
/// </summary>
/// <param name="state"></param>
private void OnRecordingStateChanged(RecordingState state)
{
if (RecordingStateChanged != null)
{
var args = new RecordingStateNotifyEventArgs() { state = state };
RecordingStateChanged(this, args);
}
}
}
}
WebSocketに接続できたかどうか確認するためにイベントを定義し、Modelからの情報をViewに表示させます。
// 接続情報をテキストボックスへ書きこむためのイベント
public event EventHandler<ResultNotifyEventArgs> ResultNotifyHandler;
/// <summary>
/// 接続状態をテキストボックスへ書き込む
/// </summary>
/// <param name="result">書き込む内容</param
private void SetText(string result)
{
if (ResultNotifyHandler != null)
{
var args = new ResultNotifyEventArgs() { result = result };
ResultNotifyHandler(this, args);
}
}
録音状態に応じてボタンの文言やBlinkを表示させたりするためにイベントを作成します。
// 録音状態を管理する変数
private RecordingState _recordingState = RecordingState.Stop;
// ここの値を変更する際にイベントを発火
public RecordingState m_recordingState
{
get
{
return _recordingState;
}
private set
{
// 状態が変化しているか
if (this._recordingState == value) return;
this._recordingState = value;
// データが変更されたときに通知することをここで集中管理
OnRecordingStateChanged(value);
}
}
// 録音状態状態の変更を通知するevent
public event ChangedEventHandler RecordingStateChanged;
/// <summary>
/// 録音状態変更イベントを発火
/// </summary>
/// <param name="sender"></param>
private void OnRecordingStateChanged(object sender)
{
if (RecordingStateChanged != null)
{
var args = new RecordingStateNotifyEventArgs() { state = state };
RecordingStateChanged(this, args);
}
}
次に最終的なWrpSimple.csは次のようになります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using RecApp.ViewModels;
namespace RecApp.Models
{
/// <summary>
/// 認識結果を扱うクラス
/// </summary>
class WrpSimple : com.amivoice.wrp.WrpListener
{
// 接続情報をテキストボックスへ書きこむためのイベント
public event EventHandler<ResultNotifyEventArgs> ResultNotifyHandler;
public void utteranceStarted(int startTime) { }
public void utteranceEnded(int endTime) { }
public void resultCreated() { }
public void resultUpdated(string result) { }
public void resultFinalized(string result)
{
string text = TextParse(result);
SetText(text);
}
public void eventNotified(int eventId, string eventMessage) { }
public void TRACE(string message) { }
/// <summary>
/// 認識結果を変換
/// </summary>
/// <param name="result">JSON形式の認識結果文字列</param>
/// <returns>認識結果
private string TextParse(string result)
{
int index = result.LastIndexOf(",\"text\":\"");
if (index == -1)
{
return null;
}
index += 9;
int resultLength = result.Length;
StringBuilder buffer = new StringBuilder();
int c = (index >= resultLength) ? 0 : result[index++];
while (c != 0)
{
if (c == '"')
{
break;
}
if (c == '\\ ')
{
c = (index >= resultLength) ? 0 : result[index++];
if (c == 0)
{
return null;
}
if (c == '"' || c == '\\' || c == '/')
{
buffer.Append((char)c);
}
else
if (c == 'b' || c == 'f' || c == 'n' || c == 'r' || c == 't')
{
}
else
if (c == 'u')
{
int c0 = (index >= resultLength) ? 0 : result[index++];
int c1 = (index >= resultLength) ? 0 : result[index++];
int c2 = (index >= resultLength) ? 0 : result[index++];
int c3 = (index >= resultLength) ? 0 : result[index++];
if (c0 >= '0' && c0 <= '9') { c0 -= '0'; } else if (c0 >= 'A' && c0 <= 'F') { c0 -= 'A' - 10; } else if (c0 >= 'a' && c0 <= 'f') { c0 -= 'a' - 10; } else { c0 = -1; }
if (c1 >= '0' && c1 <= '9') { c1 -= '0'; } else if (c1 >= 'A' && c1 <= 'F') { c1 -= 'A' - 10; } else if (c1 >= 'a' && c1 <= 'f') { c1 -= 'a' - 10; } else { c1 = -1; }
if (c2 >= '0' && c2 <= '9') { c2 -= '0'; } else if (c2 >= 'A' && c2 <= 'F') { c2 -= 'A' - 10; } else if (c2 >= 'a' && c2 <= 'f') { c2 -= 'a' - 10; } else { c2 = -1; }
if (c3 >= '0' && c3 <= '9') { c3 -= '0'; } else if (c3 >= 'A' && c3 <= 'F') { c3 -= 'A' - 10; } else if (c3 >= 'a' && c3 <= 'f') { c3 -= 'a' - 10; } else { c3 = -1; }
if (c0 == -1 || c1 == -1 || c2 == -1 || c3 == -1)
{
return null;
}
buffer.Append((char)((c0 << 12) | (c1 << 8) | (c2 << 4) | c3));
}
else
{
return null;
}
}
else
{
buffer.Append((char)c);
}
c = (index >= resultLength) ? 0 : result[index++];
}
return buffer.ToString();
}
/// <summary>
/// 接続状態をテキストボックスへ書き込む
/// </summary>
/// <param name="result">書き込む内容</param
private void SetText(string result)
{
if (ResultNotifyHandler != null)
{
var args = new ResultNotifyEventArgs() { result = result };
ResultNotifyHandler(this, args);
}
}
}
}
WrpSimple.csにもAudio.csで作成したViewに表示するためのイベントを同様に定義しています。こちらでは認識結果を表示するためにイベントを作成しています。
まとめ
今回は「Windowsアプリでマイク録音の部分を実装してみた」に挑戦してみました。皆さんもACPを利用して、音声認識アプリ開発にチャレンジしてみてください。
参考
この記事を書いた人
-
小関勇太
Pythonでサーバサイドの開発をしています。