Прототип голосового списка покупок для WP8, Win8, Android c бекендом в Azure за 2,5 часа

Источник: habrahabr
Atreides07

С 9 по 11 ноября проходил Windows 8 Хакатон RUWOWZAPP, куда я сначала зарегистрировался как участник, а потом удостоился чести присутствовать на мероприятии в качестве эксперта. Будучи в качестве эксперта я познакомиться со множеством замечательных людей и их проектов. Было настолько интересно что продолжал консультировать даже по ночам, и на сон оставалось 4-5 часов. Я настолько заразился позитивом и энергией и желанием людей создавать, что тоже не удержался от создания своего небольшого прототипа приложения - Списка покупок с поддержкой распознавания голоса. 
За пару часов мне удалось сделать функциональный прототип, демонстрирующий идею приложения, с клиентами для WP, Win8, Android 

Мне не хотелось участвовать в конкурсе приложений с таким сырым прототипом, но мне очень хотелось показать то что я сделал за пару часов, и в последний момент, перед выступлением последнего участника встал в очередь на выступление, и ведущий позволил мне продемонстрировать свои поделки:

Приложение вызвало большой интерес у участников хакатона и фактически это обещанная статья со всеми ответами на вопросы, на которые тогда у меня не хватило времени.

Для тех кто хочет сразу посмотреть код, исходники можно скачать здесь


В отличие от этого видео во время выступления я очень торопился и не успел запустить android версию. Я ожидал что основной интерес будет к тому, как именно происходит распознавания голоса, но не ожидал такого большого интереса и ко всему остальному и за последующие полчаса пару десятков человек задавали самые разные вопросы по проекту, такие как: как именно происходит синхронизация, как происходит разбор команд, как писать под андроид, на чем сделана серверная часть и т.п. Я извиняюсь что тогда у меня не было возможности всем продемонстрировать исходники проекта и эта обещанная там статья, фактически, ответы на все вопросы что мне были заданы.

Идея приложения.


Фактически изначально я хотел наконец таки в деле попробовать распознавание голоса в WP8 которое стало доступна разработчикам. И мне хотелось сделать решение, которое бы дружило именно с русским языком. 
Я остановился на следующем наборе команд:

Купить [продукт] - добавление продуктов в список
Купил [продукт] - установка галочек "куплено"
Удалить [продукт] - удаление продукта из списка
Удалить список - очистка списка
Цена [продукт] [цена] - установка цены
[продукт] в магазине [магазин] - указание магазина где можно купить продукт 

Я посчитал что смогу сделать такое приложение для трех платформ за 6 часов, забегая вперед скажу что у меня оказалось меньше времени чем я рассчитывал и успел только первые 4 команды.

Распознавания голоса. - 1 час.


WP8 очень хорошо работает с английским языком и хорошо распознает даже для моего акцента. Но при этом оказалось что возможности распознавания на русском языке гораздо ограничено. Для русского языка WP8 распознавать только заранее заданный словарь. Убил на это примерно полчаса.
Мне очень хотелось сделать именно русский язык, и так как у меня был уже опыт работы с сервисами распознавания голоса, решил на время прикрутить какой нибудь коммерческий движок распознавания голоса. Однако с тех пор как я с ними работал последний раз у них ничего не изменилось и фактически ни у кого не было автоматизированного пробного или платного периода. И так как со всеми сервисами необходимо было общаться с менеджерами, решил прикрутить для демки распознавание голоса от гугла. Я специально поискал условия использования гугловского движка распознавания голоса и не смог найти, но помнил что где то видел что нельзя использовать в коммерческих целях (хотя, возможно, ошибаюсь). Огромное спасибо Yakhnev за прекрасную статью с исходниками на C#. Понадобилось всего 10 минут для того что бы сделать из его десктопного проекта веб проект, с API для распознавания голоса. Но так как у приложения не было возможности сохранять файл на диске, а времени на переделывания распознавания в памяти не было, пришлось отказаться от бесплатного Web Role в Azure. Благо у меня уже было развернуто пара виртуалок в Azure, переделать и залить проект на сервер не составило никаких проблем. В итоге поднял сервис распознавания с точкой доступа voice.akhmed.ru/recognize.ashx - куда POST Запросом заливаю WAV файл и на выходе получаю текст.

Приложение для WP7 - 30 минут


Больше всего времени понадобилось для приложения на WP7. Но только потому что эта платформа была тестовым полигоном и за время разработки постоянно менял код.

После того как поднял сервис распознавания голоса стал вопрос о распознавании голоса на устройстве. 
Так как это был функциональный прототип, я решил выкинуть все лишнее, авторизация пользователей, обработка нажатий на кнопки, индикатор загрузки, повторная отправка, обработка ошибок (поэтому приложение может падать переодически), сохранение в БД, сохранение wav файлов и т.п.
Так как приложение надо было портировать еще и на андроид, решил прототип сделать без MVVM, поэтому получилась страшная каша кода.

Так как, теперь нам не надо было делать приложение именно под WP8 я решил сделать версию на WP7, что давало дополнительное преимущество - прототип работает на любом WP устройстве. Запись микрофона достаточно нетревиальная задача на WP7, но у меня уже была моя библиотека WPExtensions которая позволяла легко записать голос в WAV файл. В AppBar добавил одну фиктивную кнопку добавления в список записей руками и добавил кнопку с микрофоном, который при первом нажатии начинал запись, а при повторном нажатии отправлял запись на сервер и обрабатывал результат:

private bool isRecording = false;
private readonly MicrophoneWrapper microphone = new MicrophoneWrapper();
private void ApplicationBarRecordIconButton_Click(object sender, System.EventArgs e)
{
    if (!isRecording)
    {
        microphone.Record();
        PageTitle.Text= "Слушаю...";
    }
    else if (isRecording)
    {
        microphone.Stop();
        var wav = microphone.GetWavContent();
        Send(wav);
        PageTitle.Text = defaultHeader;
    }
    isRecording = !isRecording;
}

Метод отправки тоже достаточно тривиален, в нем отправляю ответ на сервер и обрабатываю полученный ответ

private void Send(byte[] wav)
{
    var client = new HttpWebClient();
    client.Post("http://voice.akhmed.ru/recognize.ashx", wav, (result) => Dispatcher.BeginInvoke(() => ParseString(result)));
}

private void ParseString(string result)
{
    logicLayer.Parse(result);
    RefreshView();
}

Было очень много вопросов о том как именно осуществляется разбор команд, что за библиотеку использую для анализа текста, как фильтруются лишние слова типа "купить" или "и". Конечно в релизе надо делать гораздо более грамотное решение с морфологическим и синтаксическим анализом, но сейчас код до безобразия прост. Я просто использую первое слово в качестве команды и фильтрую все слова до двух букв.

public void Parse(string voiceText)
{
    var words = voiceText.Split(new[]{' '}, StringSplitOptions.RemoveEmptyEntries);
    if(words.Length>1)
    {
        var command = words.First();
        if(command.Equals("купить"))
        {
            Add(words.Skip(1));
            IncrementUpdate();
        }
        if(command.StartsWith("купил"))
        {
            SetBoughtStatusTrue(words.Skip(1));
            IncrementUpdate();
        }
        if (command.Equals("удалить") // command.Equals("очистить"))
        {
            if (words[1].Equals("список"))
            {
                shopList.ShopItems.Clear();
            }
            else
            {
                RemoveShopListItems(words.Skip(1));
            }
            IncrementUpdate();
        }
    }
}

Бекенд приложения - 20 минут


Для того что бы обеспечить синхронизацию с другими устройствами, необходимо было сделать серверную часть. Конечно такой бекенд идеальный кандидат на размещение в Azure в качестве веб роли, но для прототипа можно было разместить на той же виртуалке в Azure, что и распознавание голоса. Так как у нас время сильно ограничено то имеет смысл сделать SOAP сервис, так как студия умеет генерировать быстро прокси на клиенте.
Сервис тоже прост до безобразия. У меня есть один список покупок, который я перенес с клиента на сервер (для клиента будет сгенерирован в прокси).

public class ShopList
{
    public ShopList()
    {
        ShopItems=new List<ShopItem>();
    }
    public List<ShopItem> ShopItems { get; set; }
    public int Version { get; set; }
}

public class ShopItem
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public char Valute { get; set; }
    public bool IsBought { get; set; }
}

Честно говоря два поля Price и Valute лишнее, так как я не успел их использовать, но привожу код "как есть".
Сохранение и получение списка тоже реализована очень просто

public class GroceryService : System.Web.Services.WebService
{
    private LogicLayer logicLayer = new LogicLayer();
        
    [WebMethod]
    public ShopList GetVersion()
    {
        return logicLayer.GetShopList();
    }

    [WebMethod]
    public void UploadVersion(ShopList request)
    {
        logicLayer.Update(request);
    }
}

Конечно в релизе должно быть не полное обновление списка как есть а частичное обновление измененных данных, но для прототипа и так сгодится.
Логика тоже сделана очень просто до безобразия, так как это прототип, без БД с сохранением текущего значения в БД. Честно говоря и смысла не было в такой логике, но привожу "как есть". Имена методов неудачные но не стал менять.

public class LogicLayer
{
    private static ShopList shopList = new ShopList();
        
    public ShopList GetShopList()
    {
        return shopList;
    }

    internal void Update(ShopList newshopList)
    {
        shopList = newshopList;
    }
}

В конечном итоге поднял этот сервис по адресу voicegrocery.akhmed.ru/GroceryService.asmx
Теперь вопрос как доставлять обновления клиентам? Конечно же по PushNotification. Но его реализация могла отнять много времени, которого было в обрез и я сделал запрос с клиента в 5 секунд. 

DispatcherTimer dispathcerTimer = new DispatcherTimer();
dispathcerTimer.Interval = TimeSpan.FromSeconds(5);
dispathcerTimer.Tick += dispathcerTimer_Tick;
dispathcerTimer.Start();

Логика обновления на/с клиента очень проста. 
1. Если текущая версия меньше чем получено с сервера, текущий список заменяется серверным. 
2. Если на клиенте происходит какое то изменение, то версия увеличивается на 1 и отправляется на сервер.

void dispathcerTimer_Tick(object sender, System.EventArgs e)
{
    var client = new ServiceReference1.GroceryServiceSoapClient();
    client.GetVersionCompleted += client_GetVersionCompleted;
    client.GetVersionAsync();
}

void client_GetVersionCompleted(object sender, ServiceReference1.GetVersionCompletedEventArgs e)
{
    if (e.Result.Version > logicLayer.GetVersion())
    {
        logicLayer.UpdateShopList(e.Result);
        RefreshView();
    }
}

private void IncrementUpdate()
{
    var shopListItem = new ShopList()
    {
        Version = shopList.Version + 1,
        ShopItems = shopList.ShopItems
    };
    var client = new ServiceReference1.GroceryServiceSoapClient();
    client.UploadVersionAsync(shopListItem); 
}

Портирование на Windows 8 - 10 минут.


Портирование приложения на Win8 получилось очень просто. Я не стал реализовывать распознавание голоса на клиенте и получилось одностороняя синхронизация. XAML был скопирован практически без изменений, немножко пришлось подправить код отправки на сервер. Стал чуть проще - в один метод

async void dispathcerTimer_Tick(object sender, object e)
{
    var client = new ServiceReference1.GroceryServiceSoapClient();
    var result = await client.GetVersionAsync();

    if (result.Body.GetVersionResult.Version > logicLayer.GetVersion())
    {
        logicLayer.UpdateShopList(result.Body.GetVersionResult);
        RefreshView();
    }            
}

Портирование приложения на Android - 15 минут.

Я обожаю платформу моно. Код остался практически без изменений, осталось подправить UI. Так как для Android-а представление делать значительно сложнее, я не стал тратить много времени на создание кастомного адаптера и после 5 минут откатился и сделал простой текстовый список с текстовыми крестиком в скобках:

void client_GetVersionCompleted(object sender, ru.akhmed.voicegrocery.GetVersionCompletedEventArgs e)
{
    try
    {
        list.Clear();
        var result = e.Result.ShopItems;
        foreach (var item in result)
        {
            var checkBox = item.IsBought ? "( X ) " : "(   ) ";
            list.Add(checkBox + item.Name);
        }
        this.RunOnUiThread(() =>
        {
            this.ListAdapter = new ArrayAdapter<string>(this, Resource.Layout.ListItem, list);
            ((BaseAdapter)this.ListAdapter).NotifyDataSetChanged();
        });
    }
    catch (Exception)
    {
    }
}

Портирование на iOS - отсутствует


Конечно же я думал о том что бы портировать на iOS, но так как у меня не было i-устройств а хакинтошами, который я использую в домашней разработке показывать на таких мероприятиях некорректнои было очень мало времени отложил эту идею. Тем более на ноуте с собой у меня хакинтоша не было

Итоги


Если не учитывать 40 минут которые были потрачены на исследования возможностей платформы WP8, то чуть менее чем за 2 часа с учетом затрат на заливку на сервер и мелкие багфиксы был реализован полноценный прототип, который показывает основную идею приложения и не жалко выкинуть и приступить к полноценной реализации. 
Конечно код получился очень грязный, неоптимальный, с кучей недостатков и недоделанных фич. Но функциональные прототипы как раз нужны для того что бы на "бумажном эскизе" - на черновике показать заказчику/начальству продукт который получится на выходе.

Страница сайта http://test.interface.ru
Оригинал находится по адресу http://test.interface.ru/home.asp?artId=31069