ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 한국어로 실시간 미세먼지를 알려주는 Alexa Skill 개발하기 (1)
    개발/기록 2019. 5. 1. 16:58

    한국어로 실시간 미세먼지를 알려주는 Alexa Skill 개발하기 (0)

    * 본 포스팅에서, Alexa Skill 의 기본적인 개발방법은 다루지 않습니다.

    1.

    Amazon Polly

    저는 Amazon Polly를 통해, 알레사가 말하는 한국말을 실시간으로 만들고자 합니다.

     

    Amazon Polly는 텍스트를 생생한 음성으로 변환하는 서비스로서 이를 사용하면 말을 하는 애플리케이션을 만들고 전혀 새로운 유형의 음성 지원 제품을 개발할 수 있습니다. Amazon Polly는 고급 딥 러닝 기술을 사용하여 실제 사람의 음성처럼 소리를 합성하는 텍스트 투 스피치 서비스입니다.

    출처 : AWS 공식 홈페이지

     

    2017년 말 부터, Amazon Polly가 한국 리전에 출시됨에 따라, '서연'이라는 한국어 음성을 지원하기 시작했습니다. 
    이를통해, 한국 사용자들도 쉽게 Amazon Polly 서비스를 통해 TTS를 수행할 수 있게 되었습니다.

    또한, Polly는 TTS의 결과를 오디오 파일로 저장할 수도 있습니다.

     

    그렇다면, 알렉사가 Polly를 어떻게 활용할 수 있을까요?

     

    알렉사의 유연한 장점 중 하나는, 오디오 파일을 재생시킬 수 있다는 점입니다.

     

    많은 알렉사 스킬 개발자들은, 해당 기능을 그저 음악을 재생시키는 데 사용하지만,
    저는 Polly로 변환한 음성 파일을 S3 버켓에 저장시킨 후, 해당 url을 이용해 알렉사에서 오디오 파일을 재생시켜서

    마치, 알레사가 한국말을 구사하는 것 처럼 보여줄 수 있다고 생각했습니다.

     

     

    2.

    본 아이디어를, 실시간 미세먼지를 알려주는 Skill로 개발해보도록 하겠습니다.

    앞서 언급한 과정을 적용한 한국어 미세먼지 Skill의 구조는 위와 같습니다.

     

    구조

     


    1. 사용자가 Ask 스킬이름 How's the fine dust? 등의 질문을 합니다.
    2. Alexa Device는 사용자의 음성을 받아서, Alexa Skill이 Intent(미세먼지)를 추론한 후, AWS Lambda 함수를 호출합니다.
    3. 해당 Lambda 함수에서, 에어코리아 API에 실시간 미세먼지 정보를 요청합니다.
    4. 에어코리아 API에서 미세먼지 수치를 받아서, AWS Polly를 통해, 정해진 텍스트와 함께 TTS를 수행합니다.
    5. AWS Polly를 통해 수행된 TTS mpeg파일을 S3 Bucket에 저장합니다.
    6. 해당 S3 Bucket 내 mpeg 파일의 url을 return 하게되면, Lambda에서 해당 mpeg 파일을 들려주도록 합니다.
    7. Alexa Device는 사용자에게 한국어로 미세먼지 수치를 알려줍니다.

     

    복잡해 보이기도 하지만, 기존의 Alexa Custom Skill을 호출하는 과정에, 미세먼지API, Amazon Polly, S3를 거치는 것 뿐입니다!

     

    2.

    이제, 본격적으로 코드로 구현을 해보도록 하겠습니다.

    코드는 Node.js 환경을 기준으로 작성되어 있습니다.

     

    2-1.

    const Alexa = require('ask-sdk');
    
    const LaunchRequestHandler = {
      canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
      },
      handle(handlerInput) {
        const speakOutput = 'Hello World';
    
        return handlerInput.responseBuilder
          .speak(speakOutput)
          .getResponse();
      },
    };
    
    const HelpHandler = {
      canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
          && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent';
      },
      handle(handlerInput) {
        const speakOutput = 'You can say hello to me!';
    
        return handlerInput.responseBuilder
          .speak(speakOutput)
          .getResponse();
      },
    };
    
    const CancelAndStopHandler = {
      canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
          && (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent'
            || handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent');
      },
      handle(handlerInput) {
        const speakOutput = 'Goodbye!';
    
        return handlerInput.responseBuilder
          .speak(speakOutput)
          .getResponse();
      },
    };
    
    const SessionEndedRequestHandler = {
      canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest';
      },
      handle(handlerInput) {
        console.log(`Session ended with reason: ${handlerInput.requestEnvelope.request.reason}`);
    
        return handlerInput.responseBuilder.getResponse();
      },
    };
    
    const ErrorHandler = {
      canHandle() {
        return true;
      },
      handle(handlerInput, error) {
        console.log(`Error handled: ${error.message}`);
        console.log(error.trace);
    
        return handlerInput.responseBuilder
          .speak('Sorry, I can\'t understand the command. Please say again.')
          .getResponse();
      },
    };
    
    const skillBuilder = Alexa.SkillBuilders.custom();
    
    exports.handler = skillBuilder
      .addRequestHandlers(
        LaunchRequestHandler,
        HelpHandler,
        CancelAndStopHandler,
        SessionEndedRequestHandler,
      )
      .addErrorHandlers(ErrorHandler)
      .lambda();
    


    우선, 알렉사 Custom Skill의 기본 구조 코드는 위와 같습니다.


    Github의 alexa-skills-kit-sdk-for-nodejs 레포나, 공식 Docs에서 샘플 기본 코드를 볼 수 있습니다!

    https://github.com/alexa/alexa-skills-kit-sdk-for-nodejs

    https://ask-sdk-for-nodejs.readthedocs.io/en/latest/Setting-Up-The-ASK-SDK.html

     

    2-2.

    이제, 미세먼지 정보를 알려주는 Intent인 Dust(예시)에 대한 핸들러를 추가합니다.

    const DustHandler = {
      canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
          && handlerInput.requestEnvelope.request.intent.name === 'DustIntent';
      },
      handle(handlerInput) {
        const speakOutput = 'This is Dust Intent.';
        return handlerInput.responseBuilder
          .speak(speakOutput)
          .getResponse();
      },
    };

     

    2-3.

    DustHandler에 요청이 왔을 경우, 미세먼지 API를 호출해 미세먼지 정보를 받아오도록 코드를 수정합니다.

    const rp = require('request-promise');
    
    const DustHandler = {
      canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
          && handlerInput.requestEnvelope.request.intent.name === 'DustIntent';
      },
       async handle(handlerInput) {
        try {
          let air = await rp(`http://openapi.airkorea.or.kr/openapi/services/rest/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty?serviceKey=SERVICE_KEY&numOfRows=1&pageSize=1&pageNo=1&startPage=1&stationName=측정소명&dataTerm=DAILY&ver=1.3&_returnType=json`);
          air = JSON.parse(air);
          air = air.list[0];
          const speakOutput = 'This is Dust Intent.';
          return handlerInput.responseBuilder
            .speak(speakOutput)
            .getResponse();
        } catch (e) {
          console.log(e);
          return handlerInput.responseBuilder
            .speak("Error")
            .getResponse();
        }
      },
    };
    미세먼지 API는, 에어코리아 미세먼지 API를 이용하였습니다.
    동기식으로의 처리를 위해, handle 메소드를 async 메소드로 변경해주었고,
    API와의 통신은 request-promise 모듈을 사용했습니다.

     

    2-4

    위 구조의 가장 큰 핵심인, Amazon Polly를 통한 TTS와 S3 버켓 저장에 대한 코드를 작성합니다.

    const AWS = require('aws-sdk');
    
    AWS.config.loadFromPath('./awscreds.json');
    
    const Polly = new AWS.Polly({
        signatureVersion: 'v4',
        region: 'ap-northeast-2'
    });
    const s3 = new AWS.S3();
    
    function ttsSave(air) {
      return new Promise(((resolve, reject) => {
        let pollyparams = {
            "LanguageCode": "ko-KR",
            'Text': `<speak>${air.dataTime.split(" ")[1]}의 미세먼지 농도는 ${air.pm10Value} 입니다!</speak>`,
            'TextType': "ssml",
            'OutputFormat': 'mp3',
            'VoiceId': 'Seoyeon'
        };
    
        Polly.synthesizeSpeech(pollyparams, (err, data) => {
            if (err) {
                console.log(err.message)
                reject(err);
            } else if (data) {
                let s3params = {
                    Body: data.AudioStream,
                    Bucket: "버켓이름",
                    Key: "파일명.mpeg",
                    ACL: "public-read"
                };
    
                s3.upload(s3params, (err, data) => {
                    if (err) {
                        console.log(err.message);
                        reject(err);
                    } else {
                        console.log(data.Location);
                        resolve(data.Location);
                    }
                });
            }
        });
      }));
    }
    AWS의 계정정보는 따로 awscreds 파일을 통해 인증하도록 하였습니다.

    받아온 미세먼지 정보를 측정 시각과 pm10기준 수치만 뽑아서, Polly를 통해 TTS를 수행한 후, S3 버켓에 저장합니다.
    마지막으로, 해당 파일의 url을 반환합니다.
    (동기식으로의 처리를 위해  해당 메소드는 Promise를 반환합니다.)

     

    2-5.

    DustHandler코드에, ttsSave 메소드를 호출하는 부분을 추가하면, 최종 코드가 완성됩니다!

    실제 개발시에는, Polly를 통해 S3버켓에 저장하는 부분을 모듈화해서 분리하는 것을 추천합니다!

     

    const Alexa = require('ask-sdk');
    const AWS = require('aws-sdk');
    const rp = require('request-promise');
    
    AWS.config.loadFromPath('./awscreds.json');
    
    const Polly = new AWS.Polly({
        signatureVersion: 'v4',
        region: 'ap-northeast-2'
    });
    const s3 = new AWS.S3();
    
    function ttsSave(air) {
      return new Promise(((resolve, reject) => {
        let pollyparams = {
            "LanguageCode": "ko-KR",
            'Text': `<speak>${air.dataTime.split(" ")[1]}의 미세먼지 농도는 ${air.pm10Value} 입니다!</speak>`,
            'TextType': "ssml",
            'OutputFormat': 'mp3',
            'VoiceId': 'Seoyeon'
        };
    
        Polly.synthesizeSpeech(pollyparams, (err, data) => {
            if (err) {
                console.log(err.message)
                reject(err);
            } else if (data) {
                let s3params = {
                    Body: data.AudioStream,
                    Bucket: "버켓이름",
                    Key: "파일명.mpeg",
                    ACL: "public-read"
                };
    
                s3.upload(s3params, (err, data) => {
                    if (err) {
                        console.log(err.message);
                        reject(err);
                    } else {
                        console.log(data.Location);
                        resolve(data.Location);
                    }
                });
            }
        });
      }));
    }
    
    const LaunchRequestHandler = {
      canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
      },
      handle(handlerInput) {
        const speakOutput = 'Hello World';
    
        return handlerInput.responseBuilder
          .speak(speakOutput)
          .getResponse();
      },
    };
    
    const DustHandler = {
      canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
          && handlerInput.requestEnvelope.request.intent.name === 'DustIntent';
      },
      async handle(handlerInput) {
        try {
          let air = await rp(`http://openapi.airkorea.or.kr/openapi/services/rest/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty?serviceKey=SERVICE_KEY&numOfRows=1&pageSize=1&pageNo=1&startPage=1&stationName=측정소명&dataTerm=DAILY&ver=1.3&_returnType=json`);
          air = JSON.parse(air);
          air = air.list[0];
          const response = await ttsSave(air);
          const speakOutput = `<audio src="${response}"/>`;
          return handlerInput.responseBuilder
            .speak(speakOutput)
            .getResponse();
        } catch (e) {
          console.log(e);
          return handlerInput.responseBuilder
            .speak("error")
            .getResponse();
        }
      },
    };
    
    const HelpHandler = {
      canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
          && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent';
      },
      handle(handlerInput) {
        const speakOutput = 'You can say hello to me!';
    
        return handlerInput.responseBuilder
          .speak(speakOutput)
          .getResponse();
      },
    };
    
    const CancelAndStopHandler = {
      canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
          && (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent'
            || handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent');
      },
      handle(handlerInput) {
        const speakOutput = 'Goodbye!';
    
        return handlerInput.responseBuilder
          .speak(speakOutput)
          .getResponse();
      },
    };
    
    const SessionEndedRequestHandler = {
      canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest';
      },
      handle(handlerInput) {
        console.log(`Session ended with reason: ${handlerInput.requestEnvelope.request.reason}`);
    
        return handlerInput.responseBuilder.getResponse();
      },
    };
    
    const ErrorHandler = {
      canHandle() {
        return true;
      },
      handle(handlerInput, error) {
        console.log(`Error handled: ${error.message}`);
        console.log(error.trace);
    
        return handlerInput.responseBuilder
          .speak('Sorry, I can\'t understand the command. Please say again.')
          .getResponse();
      },
    };
    
    const skillBuilder = Alexa.SkillBuilders.custom();
    
    exports.handler = skillBuilder
      .addRequestHandlers(
        LaunchRequestHandler,
        DustHandler,
        HelpHandler,
        CancelAndStopHandler,
        SessionEndedRequestHandler,
      )
      .addErrorHandlers(ErrorHandler)
      .lambda();
    

     

    알렉사는 Audio 태그를 통해 오디오 파일을 재생시킬 수 있습니다.
    해당 기능을 통해, S3버켓 내의 오디오 파일 주소를 재생하도록 하였습니다.

     

    이제, 완성된 코드를 Alexa Developer Console을 통해 등록하는 일만 남았습니다!

     

    한국어로 실시간 미세먼지를 알려주는 Alexa Skill 개발하기 (完)

    댓글

Designed by Tistory.