深夜テンションで創ってAmazonさんの審査員さんに協議させてしまった「ノリノリのサンタ」の話
この記事は ADVENTARの「するめごはんのVUI・スマートスピーカー Advent Calendar 2018」 の21日目の記事です。
Echo Showのために4つのスキルを作成した方々、お疲れ様です。
さて、今回は僕が完全に深夜テンションで作成したAlexaスキル「ノリノリのサンタ」について記載します。
相変わらず、AmazonJapanの方を大至急協議させてしまいましたので、それについても触れていきます。
Alexaスキル「ノリノリのサンタ」とは
「なんかクリスマスのスキルを創るかー」
と思って、いらすとや ではない素材を探していたら、テンションが高そうなサンタクロースの画像素材を見つけました。
なので、サンタクロースとかクリスマスとかについての雑学をググって、まとめて、僕が深夜テンションで自分で声を収録しました。
もちろん画面対応
Echo Showが来る前にリリースしたので、Echo Spotでの画面表示に対応しています。
テンション高めなサンタクロースの画像と、ノリノリな声がぞれぞれランダムに再生されます。
ソースコードの一部
めっちゃ一部ですが、たいしたことはしてないです。
ユーザーが初回起動か否かの判定と、操作説明をオーディオファイルで流しているだけです。
画像とオーディオファイルは別々にランダムで表示させています。
'use strict'; const Alexa = require('ask-sdk'); const AWS = require("aws-sdk"); const docClient = new AWS.DynamoDB.DocumentClient({region: 'ap-northeast-1'}); //音声を定義 //起動時 const smartmacchiato = '<audio src=\"https://s3XXXXXXX.mp3\" />'; //2回目以降、わしがのりのりサンタじゃあ const washiganorinori = '<audio src=\"https://s3-XXXXXX.mp3\" />'; //初回ユーザー用説明 const first_user = '<audio src=\"https://s3-XXXXXXXXXXX.mp3\" />'; : : : //トリビアの数 const norinori_01 = '<audio src=\"https://s3-XXXXXXXXXXX/norinori1.mp3\" />'; const norinori_02 = '<audio src=\"https://s3-XXXXXXXXXXX/norinori2.mp3\" />'; const norinori_03 = '<audio src=\"https://s3-XXXXXXXXXXX/norinori3.mp3\" />'; : : : //トリビアのの音声配列 var norinori_speak_array = [ norinori_01, norinori_02, norinori_03, : : : ]; //画像 const DisplayImg1 = { title: 'のりのりサンタ1', url: 'https://s3-XXXXXXXXXXXX/img/santa1.png' }; const DisplayImg2 = { title: 'のりのりサンタ2', url: 'https://s3-XXXXXXXXXXXX/img/santa2.png' }; const DisplayImg3 = { title: 'のりのりサンタ3', url: 'https://s3-XXXXXXXXXXXX/img/santa3.png' }; : : : //サンタ画像の配列 var norinori_img_array = [ DisplayImg1, DisplayImg2, DisplayImg3, : : ]; ////////////////////////////////////////////////////////////////////////// const LaunchRequestHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'LaunchRequest'; }, async handle(handlerInput) { //サンタ話のうち1つをランダムで選ぶ var factSpeakArr = norinori_speak_array; var factSpearkIndex = Math.floor(Math.random() * factSpeakArr.length); var randomSpearkFact = factSpeakArr[factSpearkIndex]; //サンタ画像のうち1つをランダムで選ぶ var factImgArr = norinori_img_array; var factImgIndex = Math.floor(Math.random() * factImgArr.length); var randomImgFact = factImgArr[factImgIndex]; // Template 6 if (supportsDisplay(handlerInput)){ const myImage1 = new Alexa.ImageHelper() .addImageInstance(randomImgFact.url) .getImage(); const myImage2 = new Alexa.ImageHelper() .addImageInstance(randomImgFact.url) .getImage(); const primaryText = new Alexa.RichTextContentHelper() .withPrimaryText('') .getTextContent(); handlerInput.responseBuilder.addRenderTemplateDirective({ type: 'BodyTemplate6', token: 'string', backButton: 'HIDDEN', backgroundImage: myImage2, image: myImage1, title: "", textContent: primaryText }); } //JSONを扱う関連 let handlerInput_json = await JSON.stringify(handlerInput, null, 2); (略) let norinori_start = washiganorinori + randomSpearkFact; try{ const queryItems = await docClient.query({ TableName: "norinoriSantaTable", KeyConditionExpression: "#userId = :userId", ExpressionAttributeNames: {"#userId": "userId"}, ExpressionAttributeValues: {":userId": JSONのユーザーID} }).promise(); try{ console.log("queryItems.Items[0].userId: " + queryItems.Items[0].userId); } catch (err){ //よろしくない実装 //初回ユーザー用のオーディオファイルにする norinori_start = first_user; console.log("user Nothing"); } } catch(err){ console.error(`[query Error]: ${JSON.stringify(err)}`); } //DynamoDBにputする情報 var item = { userId: JSONからのユーザーID, }; var params = { TableName: 'テーブル名', Item: item }; //DynamoDBにPut await putDynamo(params); //sessionAttributeを、起動後であることを示すように格納 var sessionAttribute = ''; sessionAttribute = { "SHA_state": "after_start" }; handlerInput.attributesManager.setSessionAttributes(sessionAttribute); //しゃべる音声スキルと、間に0.7秒の待機を挟み、ノリノリの話とユーザーへの操作説明をする let speechText = smartmacchiato + '<break time="0.7s"/>' + norinori_start + ask_next; return handlerInput.responseBuilder .speak('<speak>' + speechText + '</speak>') .reprompt('<speak>' + speechText + '</speak>') .withShouldEndSession(false) .getResponse(); } }; //起動直後、本アプリの継続に「はい」「次」「もっと」「のりのり」「きかせて」と答えた場合の処理 const continueHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'IntentRequest' && ((handlerInput.requestEnvelope.request.intent.name === 'AMAZON.YesIntent') || (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.NextIntent') || (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.MoreIntent') || (handlerInput.requestEnvelope.request.intent.name === 'norinoriIntent') || (handlerInput.requestEnvelope.request.intent.name === 'kikitaiIntent') ) && handlerInput.attributesManager.getSessionAttributes().SHA_state == 'after_start'; }, async handle(handlerInput,event) { //サンタ話のうち1つをランダムで選ぶ var factSpeakArr = norinori_speak_array; var factSpearkIndex = Math.floor(Math.random() * factSpeakArr.length); var randomSpearkFact = factSpeakArr[factSpearkIndex]; //サンタ画像のうち1つをランダムで選ぶ var factImgArr = norinori_img_array; var factImgIndex = Math.floor(Math.random() * factImgArr.length); var randomImgFact = factImgArr[factImgIndex]; // Template 6 if (supportsDisplay(handlerInput)){ const myImage1 = new Alexa.ImageHelper() .addImageInstance(randomImgFact.url) .getImage(); const myImage2 = new Alexa.ImageHelper() .addImageInstance(randomImgFact.url) .getImage(); const primaryText = new Alexa.RichTextContentHelper() .withPrimaryText('') .getTextContent(); handlerInput.responseBuilder.addRenderTemplateDirective({ type: 'BodyTemplate6', token: 'string', backButton: 'HIDDEN', backgroundImage: myImage2, image: myImage1, title: "", textContent: primaryText }); } //ランダムに話して、ユーザー操作を促す const speechText = randomSpearkFact + ask_next; return handlerInput.responseBuilder .speak('<speak>' + speechText + '</speak>') .reprompt('<speak>' + speechText + '</speak>') .withShouldEndSession(false) .getResponse(); } }; //DynamoDBにputする関数 function putDynamo(params) { console.log("=== putDynamo function ===" + params); docClient.put(params, function (err, data) { console.log("=== put ==="); if (err) { console.log(err); } else { console.log(data); } }); } // returns true if the skill is running on a device with a display (show|spot) function supportsDisplay(handlerInput) { var hasDisplay = handlerInput.requestEnvelope.context && handlerInput.requestEnvelope.context.System && handlerInput.requestEnvelope.context.System.device && handlerInput.requestEnvelope.context.System.device.supportedInterfaces && handlerInput.requestEnvelope.context.System.device.supportedInterfaces.Display; console.log("Supported Interfaces are" + JSON.stringify(handlerInput.requestEnvelope.context.System.device.supportedInterfaces)); return hasDisplay; }
リジェクト内容で協議させてしまうパターン
僕にとっては、もう慣れているので構わないのですが、リジェクト理由に納得がいかずに、進言したら協議の上、承認されました。
リジェクト理由は 「ノリノリ」は名詞ではないから。
日本語でスキルの呼び出し名を決める際には、原則として名詞を2つにする必要があります。
「ヒロインの告白」みたいな。 「の」は無くてもOKの場合も多いです。
で、納得いかないので以下のように伝えたら承認されました。
1.「ノリノリ」は名詞でも使われる
goo辞書で検索すると、以下のように記載されてます。
[名・形動]《動詞「乗る」の連用形を重ねた語。「ノリノリ」と書くことも多い》調子がよくて気分が高揚していること。乗りがよくて、リズミカルであること。また、そのさま。いけいけ。「乗り乗りな曲で踊る」「乗り乗りムードで一気に勝ち進む」
[名・形動]ってあるじゃん。
2.テンションが高い場合はどう表現するのか
テンションが高い状態を示す場合、具体的にAmazonさんはどういう表現なら良いのか求めました。
そしてその際に、
仮に「ハイテンションなサンタ」にした場合、【ハイテンション】こそ日本語ではないと私は解釈します。 日本語のみで状況・状態を説明するための具体的なガイドラインをください。
と、伝えました。
そしたら通った
いつも通り?、「大至急協議します」のメールが飛んでくるので、しばし待ちます。 Amazonさんから「大至急協議します」のメールが来た場合、たいていその日のうちに返答がきます。
今回は 「協議の上、認められることになりましたので、再申請をお願いします。」
とのことで、再申請したら通りました。
その後のAlexaDevSummitでつかまる
AlexaDevSummitで会場をウロウロしてたら、中の人からお声がけがありました。
A「showさんですよね!この間の件ですが・・・」
僕「(やらかしまくっているので)どの件でしょうかごめんなさi・・・」
A「ノリノリの件です。ご指摘ありがとうございました。あれはたしかに認められないと表現できないですよね。フィードバックありがとうございました!」
僕「こちらこそ、ご対応ありがとうございましたぁぁぁ!!!」
全力で土下座する体制にしようとしましたが、むしろ感謝されました。
何が言いたいかというと、最近、スキル審査結果に対してフィードバックが画面ポチポチで選べるようになったんですけども、審査員側だって、ユーザーからのご意見が欲しいわけです。
明らかに開発者側のミスは置いておいて、リジェクトされたから黙って従うだと、AmazonさんのAlexaスキル審査員さんが独りよがりの神になってしまうわけです。
なので、ちゃんと根拠を示して、自分の意見を伝えると、AmazonのAlexa担当の方々はちゃんと対応してくれますよ。
リジェクトを頂いても、認定されても、フィードバックは送りましょう。 その方がお互いにハッピーだと思います。
以上。