Cortana-Integration in Windows 10 Apps

Seit Windows 10 hat die Sprachassistentin Cortana auch einen Nebenwohnsitz am Desktop. Roman Schacherl hat bei der .NET User Group Austria und auf der NRW Conf in Wuppertal gezeigt, wie Entwickler davon profitieren können und wie Sie Ihre eigene App “Cortana-fit” machen können.

Smalltalk

Falls Sie noch nie mit Cortana gesprochen haben: lernen Sie sie erstmal kennen. Für Österreich ist Cortana leider noch nicht verfügbar, wer darauf nicht warten will, muss das Land bzw. die Region auf „Deutschland“ einstellen. Ein guter Einstieg (nach einer höflichen Begrüßung) ist die Frage „Was kann ich sagen?“, hier noch einige weitere Tipps für das erste Date:

  • Wollen wir heiraten? (Ok, für das erste Date etwas mutig. Versuchen Sie’s.)
  • Kennst du einen Witz?
  • Kannst du ein Lied singen?
  • Starte Edge
  • Wie wird das Wetter nächste Woche?
  • Wie viel ist 24 x 3 [ x Pi ]?
  • Was mache ich morgen?
  • Kannst du morgen [um 10 Uhr] einen Termin für mich eintragen?
  • Erinnere mich um 11:30 an Parkzeit.
  • Erinnere mich, wenn ich mit Daniel spreche… (Beim Schreiben eines Mails in der Mail-App oder beim nächsten Anruf wird die Notiz angezeigt)
  • Erinnere mich, wenn ich ins Büro komme…
  • Wo bin ich?
  • Wie ist der Verkehr nach Hause?

Foreground-App-Integration

Die einfachste Variante für die Integration mit Cortana ist die so genannte „Foreground-Aktivierung“: Cortana startet mittels Sprachkommando die eigene App, im OnActivated-Event kann man darauf reagieren und beispielsweise direkt auf eine Detailseite navigieren.

Die Basis für Sprachkommandos ist eine XML-Datei mit der Voice Command Definition (VCD). Die verfügbaren Elemente und Attribute finden Sie unter hier, ich verwende folgendes Beispiel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?xml version="1.0" encoding="utf-8"?>
<VoiceCommands xmlns="http://schemas.microsoft.com/voicecommands/1.2">
  <CommandSet xml:lang="de-de" Name="commandSet_de-de">
    <CommandPrefix> Test, </CommandPrefix>
    <Example> Kennst du ein Rezept mit Spargel?</Example>
 
    <Command Name="findRecipe">
      <Example> Kennst du ein Rezept mit Spargel?</Example>
      <ListenFor> Kennst du ein Rezept mit {ingredient}?</ListenFor>
      <Feedback> Suche ein Rezept mit {ingredient}...</Feedback>
      <Navigate/>
    </Command>
 
    <PhraseList Label="ingredient">
      <Item> Spargel </Item>
      <Item> Tomaten </Item>
      <Item> Eiern </Item>
      <Item> Nudeln </Item>
      <Item> Spinat </Item>
    </PhraseList>
 
  </CommandSet>
 
</VoiceCommands>

Wichtig ist ein eindeutiges CommandPrefix, das bei den Sätzen auch immer vorangestellt werden muss (in meinem Fall: Test, Kennst du ein Rezept mit Spargel?). Gerade während der Entwicklung kann es passieren, dass Sie mehrere Apps mit demselben CommandPrefix registriert haben – Cortana räumt aber auch bereits deinstalliere Apps nicht aus ihrem Katalog und hat dann möglicherweise Probleme.

Registrieren Sie anschließend das XML-Dokument, zB am Ende der App-OnLaunched-Methode. In meinem Beispiel heißt die Datei CookbookCommands.xml und liegt im Hauptverzeichnis der App.

1
2
3
4
5
var storageFile = 
  await StorageFile.GetFileFromApplicationUriAsync(
    new Uri("ms-appx:///CookbookCommands.xml"));
await VoiceCommandDefinitionManager.
    InstallCommandDefinitionsFromStorageFileAsync(storageFile);

Die App sollte damit schon per Sprache gestartet werden – idealerweise reagieren Sie aber in der App noch auf die Spracheingabe. Der erste Einsprungspunkt ist die App-OnActivated-Methode, sorgen Sie darin für eine entsprechende Behandlung und die Anzeige einer Seite. Auf Platzhalter in Ihren Sprachkommandos können Sie über das SpeechRecognitionResult zugreifen.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
protected override async void OnActivated(IActivatedEventArgs args)
{
    base.OnActivated(args);
 
    if (args.Kind == ActivationKind.VoiceCommand)
    {
        var commandArgs = args as VoiceCommandActivatedEventArgs;
        var speechRecognitionResult = commandArgs.Result;
 
        var ingredient = speechRecognitionResult.SemanticInterpretation.Properties["ingredient"].FirstOrDefault();
 
        var recipes = await RecipeService.GetRecipeDetails();
        var recipe = recipes.Where(p => p.Title.Contains(ingredient)).FirstOrDefault();
 
        if (recipe != null)
        {
            Frame rootFrame = Window.Current.Content as Frame;
 
            if (rootFrame == null)
            {
                rootFrame = new Frame();
                Window.Current.Content = rootFrame;
            }
 
            rootFrame.Navigate(typeof(RecipeDetailView), recipe.Id);
 
            Window.Current.Activate();
        }
    }
}

In unserer bisherigen Command-Datei wurden die Phrasen für die gewünschte Zutat („ingredient“) fix festgelegt – sinnvollerweise wird man diese aber dynamisch setzen. Nach der erfolgreichen Installation des XML (OnLaunched) können Sie die CommandDefinition wieder laden und beliebig verändern.

1
2
3
4
5
6
7
8
9
10
VoiceCommandDefinition commandSet;
 
if (VoiceCommandDefinitionManager.InstalledCommandDefinitions
                 .TryGetValue("commandSet_de-de", out commandSet))
{
    var recipes = await RecipeService.GetRecipes();
    var words = recipes.SelectMany(p => p.Title.Split(' '));
 
    await commandSet.SetPhraseListAsync("ingredient", words);
}

Background-App-Integration

Ihre Stärke spielt Cortana seit Windows 10 aber vor allem in einer dezenteren Integration aus: während der Benutzer mit Cortana spricht, liefert unsere App die Antworten. Diese Background-Integration benötigt das selbe CommandDefinition-XML wie vorhin mit nur einer Änderung: anstelle des Navigate-Elements kommt ein VoiceCommandService zum Vorschein:

1
2
3
4
5
6
<Command Name="findRecipe">
  <Example> Kennst du ein Rezept mit Spargel?</Example>
  <ListenFor> Kennst du ein Rezept mit {ingredient}?</ListenFor>
  <Feedback> Suche ein Rezept mit {ingredient}...</Feedback>
  <VoiceCommandService Target="RecipeVoiceCommandService"/>
</Command>

Erstellen Sie ein eigenes Windows Runtime Component-Projekt in Visual Studio und erstellen Sie eine neue Klasse, die IBackgroundTask implementiert. Ähnlich wie vorhin können Sie auf die Platzhalter in Ihrer Grammatik zugreifen, als Antwort liefern Sie eine VoiceCommandResponse.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
public sealed class RecipeVoiceCommandService : IBackgroundTask
{
    private BackgroundTaskDeferral deferral;
    private VoiceCommandServiceConnection voiceServiceConnection;
 
    public async void Run(IBackgroundTaskInstance taskInstance)
    {
        deferral = taskInstance.GetDeferral();
 
        try
        {
            var triggerDetails = taskInstance.TriggerDetails as AppServiceTriggerDetails;
 
            if (triggerDetails.Name == "RecipeVoiceCommandService")
            {
                voiceServiceConnection = VoiceCommandServiceConnection.FromAppServiceTriggerDetails(triggerDetails);
 
                var voiceCommand = await voiceServiceConnection.GetVoiceCommandAsync();
 
                if (voiceCommand.CommandName == "findRecipe")
                {
                    var ingredient = voiceCommand.Properties["ingredient"].FirstOrDefault();
 
                    if (ingredient != null)
                    {
                        var recipes = (await RecipeService.GetRecipes()).Where(p => p.Title.Contains(ingredient)).ToList();
 
                        var message = new VoiceCommandUserMessage();
                        var tiles = new List<VoiceCommandContentTile>();
 
                        if (recipes.Count == 0)
                        {
                            message.SpokenMessage = ${body}quot;Ich konnte leider kein Rezept mit {ingredient} finden.";
                            message.DisplayMessage = message.SpokenMessage;
                        }
                        else
                        {
                            if (recipes.Count == 1)
                            {
                                message.SpokenMessage = "Hier ist dein Rezept:";
                            }
                            else
                            {
                                message.SpokenMessage = "Ich habe mehrere Rezepte gefunden:";
                            }
 
                            message.DisplayMessage = message.SpokenMessage;
 
                            foreach (var recipe in recipes)
                            {
                                var tile = new VoiceCommandContentTile();
                                tile.ContentTileType = VoiceCommandContentTileType.TitleWith68x68IconAndText;
                                tile.AppLaunchArgument = ${body}quot;recipeId={recipe.Id}";
                                tile.Title = recipe.Title;
                                tile.TextLine1 = ${body}quot;Bewertung: {recipe.Rating}/5";
 
                                tile.Image = await StorageFile.CreateStreamedFileFromUriAsync(
                                    ${body}quot;recipe{recipe.Id}.png",
                                    new Uri(recipe.ImagePath, UriKind.Absolute),
                                    null);
 
                                tiles.Add(tile);
                            }
                        }
 
                        var response = VoiceCommandResponse.CreateResponse(message, tiles);
                             
                        await voiceServiceConnection.ReportSuccessAsync(response);
                    }
                }
            }
        }
        finally
        {
            if (deferral != null)
            {
                await Task.Delay(2000);
                deferral.Complete();
            }
        }
    }
}

Referenzieren Sie das neue Projekt aus dem UI-Projekt heraus, damit es im Deployment enthalten ist. Zusätzlich benötigen Sie noch einen Eintrag in der package.appxmanifest-Datei, um das App-Service bekannt zu machen. Der hier definierte Name muss mit dem VoiceCommandService-Target-Attribut im XML übereinstimmen.

1
2
3
4
5
6
7
8
<Applications>
  <Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="Cookbook.App">
    <Extensions>
      <uap:Extension Category="windows.appService"
                     EntryPoint="Cookbook.VoiceCommandService.RecipeVoiceCommandService">
        <uap:AppService Name="RecipeVoiceCommandService"/>
      </uap:Extension>
    </Extensions>

Starten Sie die App einmal und versuchen Sie, Cortana anzusprechen. Bevor Sie sich an die Fehlersuche machen: es kann 2-3 Versuche dauern, bis Cortana reagiert.

Viel Erfolg!