Raspberry pi

4편 적외선 리모콘 (IR Remote Controller) + 음성인식 개선

[혜안] 2017. 4. 17. 23:06
728x90

[Raspberry pi] - 1편 적외선 리모콘 (IR Remote Controller)

[Raspberry pi] - 2편 적외선 리모콘 (IR Remote Controller)

[Raspberry pi] - 3편 적외선 리모콘 (IR Remote Controller) + 음성인식

[Raspberry pi] - 4편 적외선 리모콘 (IR Remote Controller) + 음성인식 개선

[Raspberry pi] - 5편 적외선 리모콘 (IR Remote Controller) + TV 스피커리시버 연동




편한 방법을 찾아 돌고돌다 결국은 Google API를 이용한 음성인식 앱을 제작했다.


느려터진 Android Studio를 살살 달래가며, (사실 내마음을 다스려가며...) 오랜 기억을 되집어 본격적인 앱 개발을 시작.


우선 설계는,

기본 Activity,

백그라운드 Service,

전등을 켜고 끄는 Plug 위젯,

세가지로..


디자인 제로, 기능 위주...


그러나 결론적으로 Service는 제외 되었다.

Service를 만들고 TTS까지 폼나게 붙여서 "말씀하세요, 듣고있습니다."를 만들어놓은 첫날, 

오작동으로 인해 한밤중에 자꾸만 말씀하라는 안내를 들어야만 했다. ㅜㅜ


밤새 짜증도 났고, 

구글링을 해봐도 핸드폰이 꺼져있는 상태에서 키워드 단어로 앱을 살리는 기능은 무리가 있었다.

결국, 자꾸만 살아나는 Service는 빼고 Activity에 기능을 실어서, Ok Google에 의지하는 방향으로...

원했던 상시대기 기능은 라즈베리파이에 마이크를 심는날을 기약하기로 하고..


시나리오는,

1. 우선 Ok Google을 외친다.

2. 앱 이름을 부른다. (실행 안되는 줄 알았는데, 앱 실행은 후순위라 다른 액션이 자주 일어남. 따라서 앱 이름이 단순해야 한다.)

3. 앱이 실행되면 "말씀하세요" 라는 TTS 를 내보내고, 음성인식 대기

4. 정해진 명령어를 외치면 해당 동작 수행, 동작 결과를 TTS로 안내

5. 그리고 다시 음성인식 대기

6. 만약 음성입력이 없으면 "듣고있습니다"를 내보내고 다시 대기

7. "수고했어"라는 음성을 들으면 앱 종료


여기에 필요한 기능은 두가지이다.

1. 음성을 텍스트로 변환 : SpeechRecogniger

2. 텍스트를 음성으로 변환 : TextToSpeech


SpeechRecogniger 와 TextToSpeech 둘 다 Google API이지만, TextToSpeech 엔진은 구글 TTS와 삼성 TTS 중 선택할 수 있다.

Android OS에서 사용자가 설정한 기본 모듈로 연결해 주는데, 둘 다 해본결과 처음에는 삼성TTS가 여자목소리임에도 잔잔한게 서재에서 작업할때는 좋았다.

그러나 정작 테스트하면서 TV소리와 함께 들으니 너무 진지하게 들려서 도중에 구글 TTS로 바꾸었다.  


SpeechRecogniger


1. onCreate 에서 SpeechRecogniger 초기화

mRecognizerIntent = new Intent(RecognizerIntent.ACTION_WEB_SEARCH);
mRecognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
mRecognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.KOREAN);
mRecognizerIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1);
mRecognizer = SpeechRecognizer.createSpeechRecognizer(this);
mRecognizer.setRecognitionListener(mRecognitionListener);


2. 인식된 음성을 받을 리스너 생성

private RecognitionListener mRecognitionListener = new RecognitionListener() {

@Override
public void onRmsChanged(float rmsdB) {
int step = (int) (rmsdB / 7); //소리 크기에 따라 step을 구함.
//Log.d(TAG, "Rms:" + String.valueOf(step));
}

@Override
public void onResults(Bundle results) {
ArrayList<String> rsts = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);

startAction(rsts);
}

@Override
public void onReadyForSpeech(Bundle params) {
Log.d(TAG, "onReadyForSpeech");
}

@Override
public void onEndOfSpeech() {
Log.d(TAG, "onEndOfSpeech");

reserveListen();
}

@Override
public void onError(int error) {
String message;
switch (error) {
case SpeechRecognizer.ERROR_AUDIO:
message = "Audio recording error";
break;
case SpeechRecognizer.ERROR_CLIENT:
message = "Client side error";
break;
case SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS:
message = "Insufficient permissions";
break;
case SpeechRecognizer.ERROR_NETWORK:
message = "Network error";
break;
case SpeechRecognizer.ERROR_NETWORK_TIMEOUT:
message = "Network timeout";
break;
case SpeechRecognizer.ERROR_NO_MATCH:
message = "No match";
break;
case SpeechRecognizer.ERROR_RECOGNIZER_BUSY:
message = "RecognitionService busy";
break;
case SpeechRecognizer.ERROR_SERVER:
message = "error from server";
break;
case SpeechRecognizer.ERROR_SPEECH_TIMEOUT:
message = "No speech input";
break;
default:
message = "Didn't understand, please try again.";
break;
}
Log.d(TAG, "test error :" + message);
reserveListen();
mSayToggle = false;//에러가 난 경우 "듣고있습니다" 생략
mHandler.postDelayed(new ReadyListen(), LISTEN_DELAY_SHORT);//에러가 난 경우 빠르게 호출
}

@Override
public void onBeginningOfSpeech() {
} //입력이 시작되면

@Override
public void onPartialResults(Bundle partialResults) {
} //인식 결과의 일부가 유효할 때

@Override
public void onEvent(int eventType, Bundle params) {
} //미래의 이벤트를 추가하기 위해 미리 예약되어진 함수

@Override
public void onBufferReceived(byte[] buffer) {
} //더 많은 소리를 받을 때
};


3. Record Permission 요청/검사 로직 수행 후 SpeechRecogniger 실행

주요 권한들은 러닝타임에 Permission을 한번 더 받도록 바뀌었단다. 앱 설치할때에만 받았었는데,,,

최초에 한번 팝업이 떠서 Record 권한을 허용하면 이후부터는 패스된다.

여기서는 앱이 실행되려면 항상 거치는 onResume에 권한검사를 넣은 후, 권한이 없으면 권한요청을 하고  onRequestPermissionsResult 를 통해 허용여부 결과를 받는다. 이미 권한이 있으면 바로SpeechRecogniger 를 실행한다. intro() 메소드에 실행 로직이 있다.

@Override
protected void onResume() {
Log.d(TAG, "onResume");

mTts = new TextToSpeech(this, this);

mHandler = new Handler();

mRecognizerIntent = new Intent(RecognizerIntent.ACTION_WEB_SEARCH);
mRecognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
mRecognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.KOREAN);
mRecognizerIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1);
mRecognizer = SpeechRecognizer.createSpeechRecognizer(this);
mRecognizer.setRecognitionListener(mRecognitionListener);

if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {

if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.RECORD_AUDIO)) {
Log.d(TAG, "waitingPermission");
} else {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.RECORD_AUDIO}, 1);
Log.d(TAG, "requestListening");
}
} else {
intro();
}
super.onResume();
}

@Override
public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
Log.d(TAG, "onRequestPermissionsResult");
switch (requestCode) {
case 1: {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
intro();
} else {
Log.d(TAG, "rejectedListening");
}
return;
}
}
}

※ 러닝타임에 권한 요청이 있지만, 그렇다고 권한 취득을 위한 매니패스트 등록이 필요없는건 아니다.

    아래와 같이 android.permission.RECORD_AUDIO" 권한을 추가해야 한다.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.viewise.home.voiceassistance">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

................. 후략



TextToSpeech

1. Activity 클래스에 TextToSpeech.OnInitListener를 implements 한다.

public class MainActivity extends AppCompatActivity implements TextToSpeech.OnInitListener


2. OnInit 메소드를 Override 하고 TTS엔진이 초기화되면 안내음성을 내보내도록 한다.

@Override
public void onInit(int status) {
if (status == TextToSpeech.SUCCESS) {
mTts.setLanguage(Locale.KOREAN);
Log.d(TAG, "TTS Status : SUCCESS");
mTtsReady = true;
} else {
Log.d(TAG, "Could not initialize TextToSpeech.");
}
}

private void intro() {
if (mTtsReady == false) {
Log.d(TAG, "intro Delayed");
mHandler.postDelayed(new DelayedTts(), 300);
return;
}

say(getString(R.string.app_name) + "입니다. 말씀하세요.", "hello");
mCmd = CMD_CONTINUE;
Log.d(TAG, "mCmd = CMD_CONTINUE");
beingListenStart = false;
mSayToggle = false;
mHandler.postDelayed(new StartListen(), LISTEN_DELAY);
if(!mTestMode)
mHandler.postDelayed(new ReadyListen(), LISTEN_NEXT);//Listen 타이머 시작
}

private void say(String str, String id) {
mTts.speak(str, TextToSpeech.QUEUE_FLUSH, null, id);
Log.d(TAG, str);
txtSay.setText(str);
}



화면 구성


화면은 간단하다.

상단에 TTS, 즉 앱에 말하는 텍스트를 표시하고,

하단에 내가 말한걸 해석한 텍스트를 표시한다.

하단에 해석한 텍스트는 구글 음성인식이 추측한 후보군들을 여러개 표출하도록 했다.

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
tools:context="com.viewise.home.voiceassistance.MainActivity">

<TextView
android:id="@+id/txtSay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="25dp"
android:layout_marginStart="25dp"
android:layout_marginTop="25dp"
android:onClick="onClick"
android:textSize="24sp" />

<TextView
android:id="@+id/txtListen"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/txtSay"
android:layout_marginEnd="25dp"
android:layout_marginStart="25dp"
android:layout_marginTop="25dp"
android:textSize="24sp" />

</RelativeLayout>


실행화면은 아래와 같다.


실행 동영상을 첨부하겠지만, "볼륨 올려" 라는 명령을 내렸고, 첫 번째 결과가 정확하다. 

시험결과 아주 가끔 씩 두번째나 세번째 결과값이 맞고, 대부분은 첫 번째 결과가 맞는다.

S보이스 대비 확연히 좋은 인식율이었다.




라즈베리파이(Raspberry Pi)에 요청 보내기


JSON 타입으로 요청과 응답 포멧을 맞추었지만 아직 보안기능이 들어가지는 않았다.

그리고 응답도 뻔하다... result:ok 

그래서 안받는다... 아직은...

private void startAction(ArrayList<String> msg) {
String strTts = "";
if (findMessage(msg, "주방 tv 켜") || findMessage(msg, "주방 tv 실행")) {
strUrl = strPower;
strTts = "주방 tv를 켰습니다.";


............... 중략

strTts = "스탠드를 껐습니다.";
} else if (findMessage(msg, "수고") || findMessage(msg, "종료")) {

say("네에. 종료합니다.", "bye");

mCmd = CMD_STOP;
Log.d(TAG, "mCmd = CMD_STOP");

mHandler.postDelayed(new DelayedStop(), 300);
//moveTaskToBack(true);
return;
} else {
StringBuilder sb = new StringBuilder();
for (String t : msg) {
Log.d(TAG, t);
sb.append(t + "\r\n");
}
txtListen.setText(sb.toString());
return;
}

new Thread() {
public void run() {
HttpURLConnection conn = null;
try {
URL url = new URL(strUrl);
url.openConnection();
conn = (HttpURLConnection) url.openConnection();
conn.getInputStream();

} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (conn != null)
conn.disconnect();
}
}
}.start();

say(strTts, "exe");
}



테스트 테스트

Ok Google로 깨워서 앱을 실행한 후,

몇가지 TV 명령을 하고 종료시키는 영상이다.

TV 소리가 섞임에도 제법 인식을 잘한다. 

한가지 제약조건! Ok Google은 스마트폰 화면이 켜져있을때, 또는 충전케이블이 연결되어 있을 때에만 작동한다.



전등을 켜고 끄는 앱위젯은 다음 포스트에....

728x90