しっぽを追いかけて

ぐるぐるしながら考えています

Unity と猫の話題が中心   掲載内容は個人の私見であり、所属組織の見解ではありません

Xamarin で各プラットフォームの音声合成

前回 から続いて Xamarin.Forms による RSS リーダー

とりあえず英語の記事は読めるようになりましたが、自信がないのはヒアリングの方なので音声読み上げの機能を追加したいと思います。

下記の記事で iOSWindows Phone については実装してみたのですが、Android は今回お初です

matatabi-ux.hateblo.jp

音声合成はプラットフォームごとの分岐が必要です

f:id:matatabi_ux:20150819073539p:plain

ただ、普通に実装してしまうと保守性が下がるので、DI コンテナ Unity を使って各プラットフォームごとにサービスクラスを作りインタフェースごしに Xamarin.Forms からアクセスします*1

Android の場合以前の記事で紹介した iOSWindows Phone と少し異なります

/// <summary>
/// Text speech service class
/// </summary>
public class TextSpeechService : Java.Lang.Object, ITextSpeechService, TextToSpeech.IOnInitListener
{
    /// <summary>
    /// Text speech synthesizer
    /// </summary>
    private TextToSpeech synthesizer;

    /// <summary>
    /// Flag wether service is initialized or not
    /// </summary>
    private bool isInitialized;


    /// <summary>
    /// Speak language
    /// </summary>
    private Locale language = Locale.Us;

    /// <summary>
    /// Constructor
    /// </summary>
    public TextSpeechService()
    {
        this.synthesizer = new TextToSpeech(Forms.Context, this);
    }

    /// <summary>
    /// Text speech service initialized event handler
    /// </summary>
    /// <param name="status">Initialized status</param>
    public void OnInit([GeneratedEnum] OperationResult status)
    {
        if (!status.Equals(OperationResult.Success))
        {
            return;
        }
        this.isInitialized = true;
    }

    /// <summary>
    /// Set speaking a language
    /// </summary>
    /// <param name="language">speak language name</param>
    public void SetLanguage(string language)
    {
        switch (language)
        {
            case "Japanese":
                this.language = Locale.Japanese;
                break;

            default:
                this.language = Locale.Us;
                break;
        }
    }

    /// <summary>
    /// Speak text sentence
    /// </summary>
    /// <param name="text">target text</param>
    public void Speak(string text)
    {
        if (!this.isInitialized)
        {
            // Ignore request
            return;
        }
        this.Stop();
        this.synthesizer.SetSpeechRate(0.8f);
        this.synthesizer.SetLanguage(this.language);

        if (((int)Build.VERSION.SdkInt) >= 21)
        {
            this.synthesizer.Speak(text, QueueMode.Flush, Bundle.Empty, UUID.RandomUUID().ToString());
        }
        else
        {
            #pragma warning disable 618

            // This method was deprecated in API level 21.
            this.synthesizer.Speak(text, QueueMode.Flush, null);
                
            #pragma warning restore 618
        }
    }

    /// <summary>
    /// Stop speaking
    /// </summary>
    public void Stop()
    {
        if (this.synthesizer.IsSpeaking)
        {
            this.synthesizer.Stop();
        }
    }

    /// <summary>
    /// Dispose service
    /// </summary>
    public void Uninitialize()
    {
        this.synthesizer.Shutdown();
    }
}

音声合成機能の初期化のため、TextToSpeech.IOnInitListener のインタフェースを実装しないといけないみたいです・・・ちょっとめんどくさい;

あとは音声言語を設定できるメソッドも追加しました

また読み上げ速度も変更したかったので、Windows Phone では下記のように特殊な呼び出し方に変えました

/// <summary>
/// Text speech service class
/// </summary>
public class TextSpeechService : ITextSpeechService
{
    /// <summary>
    /// Text speech synthesizer
    /// </summary>
    private SpeechSynthesizer synthesizer;
    /// <summary>
    /// Speak language
    /// </summary>
    private string language = "en-US";

    /// <summary>
    ///     Constructor
    /// </summary>
    public TextSpeechService()
    {
        this.synthesizer = new SpeechSynthesizer();
    }

    /// <summary>
    /// Set speaking a language
    /// </summary>
    /// <param name="language">speak language name</param>
    public void SetLanguage(string language)
    {
        switch (language)
        {
            case "Japanese":
                this.language = "ja-JP";
                break;

            default:
                this.language = "en-US";
                break;
        }
    }

    /// <summary>
    /// Speak text sentence
    /// </summary>
    /// <param name="text">target text</param>
    public async void Speak(string text)
    {
        this.Stop();
        try
        {
            await this.synthesizer.SpeakSsmlAsync(
                string.Format(@"<speak version='1.0' 
                                    xmlns='http://www.w3.org/2001/10/synthesis' 
                                    xml:lang='{0}'>
                                <prosody rate='-0.2' volume='250'>{1}</prosody>
                            </speak>", this.language, WebUtility.HtmlEncode(text)));
        }
        catch (Exception)
        {
            // Ignore task cancelation or interrupted exception
        }
    }

    /// <summary>
    /// Stop speaking
    /// </summary>
    public void Stop()
    {
        this.synthesizer.CancelAll();
    }

    /// <summary>
    /// Dispose service
    /// </summary>
    public void Uninitialize()
    {
        this.synthesizer.Dispose();
    }
}

とりあえず ListView のアイテムを選択した際に音声読み上げを行うよう、XAMLイベントハンドラを生やします

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="XamarinReader.Views.TopPage"
             x:Name="root"
             BindingContext="{Binding Path=ViewModel, Source={x:Reference Name=root}}">
    <ContentPage.Padding>
        <OnPlatform x:TypeArguments="Thickness"
                    iOS="0,20,0,0"/>
    </ContentPage.Padding>
    <StackLayout Orientation="Vertical"
                 Spacing="10">
        <StackLayout Orientation="Horizontal"
                     Padding="15,5"
                     BackgroundColor="#33ffffff"
                     Spacing="5">
            <Label Text="TechCrunch"
                   TextColor="Lime"
                   FontSize="Medium"/>
            <Label Text="Reader"
                   FontSize="Medium"/>
        </StackLayout>
        <ListView ItemsSource="{Binding Items}"
                  SeparatorColor="Transparent"
                  HasUnevenRows="True"
                  IsRefreshing="{Binding IsRefresing}"
                  IsPullToRefreshEnabled="True"
                  RefreshCommand="{Binding RefreshCommand}"
                  ItemSelected="OnListItemSelected">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <ViewCell>
                        <StackLayout Orientation="Vertical"
                                     Spacing="0"
                                     Padding="15,10">
                            <Image Source="{Binding Thumbnail}"
                                   HeightRequest="150"
                                   VerticalOptions="StartAndExpand"
                                   Aspect="AspectFill"/>
                            <StackLayout Orientation="Vertical"
                                         Padding="10"
                                         BackgroundColor="#66000000"
                                         Spacing="10">
                                <Label Text="{Binding Title}"
                                       TextColor="Accent"
                                       FontSize="Medium"/>
                                <Label Text="{Binding Description}"
                                       TextColor="Default"
                                       FontSize="Small"/>
                            </StackLayout>
                        </StackLayout>
                    </ViewCell>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
    </StackLayout>
</ContentPage>

OnListItemSelected のイベントハンドラはコードビハインドに追加

/// <summary>
/// Top page
/// </summary>
public partial class TopPage : ContentPage
{
    ~ 中略 ~

    /// <summary>
    /// Text speech service 
    /// </summary>
    private ITextSpeechService speech = null;

    /// <summary>
    /// ViewModel
    /// </summary>
    public TopPageViewModel ViewModel { get; private set; }

    /// <summary>
    /// Constructir
    /// </summary>
    public TopPage()
    {
        this.ViewModel = new TopPageViewModel();
        this.ViewModel.RefreshCommand = DelegateCommand.FromAsyncHandler(this.Refresh);
        this.newsFeed = ((App)App.Current).Container.Resolve<INewsFeedService>();

        InitializeComponent();
    }

    /// <summary>
    /// Appearing event handler
    /// </summary>
    protected override async void OnAppearing()
    {
        base.OnAppearing();

        this.speech = ((App)App.Current).Container.Resolve<ITextSpeechService>();
        await this.Refresh();
    }

    ~ 中略 ~

    /// <summary>
    /// ListView item selected event handler
    /// </summary>
    /// <param name="sender">Event publisher</param>
    /// <param name="e">Event arguments</param>
    private void OnListItemSelected(object sender, SelectedItemChangedEventArgs e)
    {
        var item = e.SelectedItem as NewsItemViewModel;
        if (item == null)
        {
            return;
        }

        this.speech.SetLanguage("English");
        this.speech.Speak(string.Format("{0}: {1}", item.Title, item.Description));
    }
}

他は以前の記事とほぼ同じなので割愛!

動かしてみた様子はこんな感じ

*1:詳しくは Xamarin の救世主 Unity! - Qiita を参照ください