概述
Clova 是韩国Naver公司开发的语音服务平台,与Amazon的Alexa腾讯的叮当很类似。它有全栈的技术包括ASR,TTS,NLP,开放的技能平台,丰富的内容与生态包含音乐、电台、有声资源、IOT平台等。这样的完善的平台是悟空出海最佳的选择,稍显遗憾的是当前只支持韩语与日语。
AlphaMini的语音服务的代码工程:[https://10.10.1.34/alpha_mini/Clovar](https://10.10.1.34/alpha_mini/Clovar)
下面将以如下方面讲解接入的具体过程:
- 设置发音人
- 接入麦克风阵列
- 自定义技能
- 唤醒逻辑的改造
- DeviceControl的实现
- 各Audio Layer前后台的监听
- 语音交互状态的监听
- Notification的实现
专有名词说明
-
CIC :
CIC is a platform that serves as an interface between Clova and a client providing AI assistant services on computers, mobile apps, mobile devices, or home appliances
CIC说明.png
-
Event : Client端向Clova Server端发送的请求,一般是发送语音识别、文本识别请求,或者Client端各种状态的同步。
-
Directive:由Clova Server向Client发送的指令,一般通过event的请求的response下发,或者DownChannel来下发。
SDK包含的内容
Naver提供了完整的sdk和demo, demo可以直接编译安装,就可以完成语音交互功能。
sdk包含了登录授权、语音唤醒、语音交互、 内容TTS的播报、音乐、有声资源的播放、多种音频Layer的前后台管理等。这些都包含在SDK里打包成多个aar,它提供了完整的封闭的接入方案, 但同时又缺乏灵活,如果我们的硬件是一个带触摸屏的音响接入会很方便,但针对人形机器人这种形态的产品,接入就不是特别友好了,定制化修改变得很困难。
Clova Sdk与SpeechFramework SDK的结合
SpeechFramework sdk:是为了方便接入新的语音服务平台, 我们把麦克风阵列的访问、声源定位、唤醒的交互、语音识别过程的交互、登录授权的逻辑整合成了sdk,减少重复代码的开发。
一般通过实现ISpeechInterface接口来与语音平台的sdk进行结合。
SpeechFramework的详细说明见:
https://www.jianshu.com/p/0ab77f093633
在实际项目中ISpeechInterface的实现类是UBTCloverSpeechImpl。
初始化、环境变量设置
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.clientId, ServerSetting.get(context, ServerSetting.Key.ClientId))
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.clientSecret, ServerSetting.get(context, ServerSetting.Key.ClientSecret))
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.manufacturerId, "Clova")
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.productId, "SDKSample")
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.firmwareVersion, BuildConfig.VERSION_NAME)
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.hardwareInfo, Build.HARDWARE)
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.extraInfo, "target=US;other=hello")
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.modelId, "test_app")
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.keyword, KeywordSetting.get(context).selectKeyword.name)
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.recognizedBufferReceived, true.toString())
//UBT: 此参数用来控制AudioLayer的前后台播放的规则,详情见:AudioLayerRule.getFilePath()
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.audioLayerType, "DEVICE")
//UBT Add
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.deviceId, SysApi.get().readRobotSid())
it.addClovaEnvironmentVariable(ClovaEnvironment.Key.deviceName, SysApi.get().readRobotSid())
登录授权
Clova 支持Naver,Line两种账号体系的登录授权。 为什么是这两个,因为这两个产品都是Naver自家的。
Naver号称韩国的百度,他是韩国最大的韩语搜索引擎公司,同时又有一款新闻类的App Naver,它的日本分公司开发出了Line,是日本最火的即时通信软件,但在韩国使用者寥寥。所以 Clova在韩国版支持Naver登录、在日语版支持Line。
我们当前只接入了韩语版,所以在这里主要讲Naver的登录授权过程,等后续接入了日文版再补充Line的登录授权过程。
因为悟空是没有屏幕的,所以登录授权的过程放在了手机Companion App上,因为最后是机器人端去访问Clova Server, 所以机器人端是需要拿到访问服务的accessToken。 accessToken是要用code去换取的,所以Companion App需要把code传给机器人。 这个过程与叮当的授权过程一样。这种授权方式,可以叫跨端授权。
-
替换ClientId、ClientSecret
登录授权过程中涉及两组clientId/clientSecret, 一个是Naver的开放平台分配的,一个是Clova平台分配的。(在跟clova建立合作关系时,naver帮我们分配好了)
在最新版本的sdk里,这两个ClientId、ClientSecret都保存在demo的/sample/src/main/assets/server/external.json文件里(老版的是放在build.gradle里的,对新版本的做法表示不解, 放在assets目录里只要apk释放出去了,clientId/clientScret就泄露了,安全性还不如放在build.gradle里)。
-
Naver登录
因为我们采取的是跨端授权,naverClientId用于Naver账号的登录所以naverClientId,在机器人端用不到,在手机端设置即可。
在Demo里, Naver登录的相关代码是在RunOptionActivity.kt和sdk OAuthLogin类里, 对于Android App端可以把这部分的代码移植到手机App端。Ios App端,可以从Naver官网下载登录的sdk,。
-
给机器人授权访问Clova Server
授权部分: 登录成功后, 带上ClovaClientId/ClovaClientSecret访问Clova Server 获取到code,关键代码在
ClovaLoginProxyActivity和LoginManager类里。
对于ios App,这部分没有sdk, 只能调用他们提供的restful Api, Api详情如下:
$ curl -H "Authorization: Bearer Zc3d3QAR6zIxqceOpXoq"
authorize \
--data-urlencode "client_id=c2Rmc2Rmc2FkZ2Fasdkjh234zZnNhZGZ" \
--data-urlencode "device_id=aa123123d6-d900-48a1-b73b-aa6c156353206" \
--data-urlencode "model_id=test_model" \
--data-urlencode "response_type=code" \
--data-urlencode "state=FKjaJfMlakjdfTVbES5ccZ"
更详细的请参考https://developers.naver.com/docs/clova/client/Develop/References/Clova_Auth_API.md
获取code后可以通过蓝牙或IM把code传给机器人,机器人端调用LoginManager.getClovaAccessToken(code, accessTokenResponseInterface);
方法获取accessToken/refreshToken。 这部分逻辑需要我们自己实现,详细见UBTAuthorizationManager.java
-
解绑与删除设备
解绑 是从我们开发的Companion App发起的, 收到后台的IM push后, 开始走退出登录和清除的逻辑:
private void clearAccessToken() {
Bundle bundle = new Bundle();
bundle.putString("reason", "headTap");
Master.get().execute(MusicSkill.class, musicSkill -> musicSkill.stopSkill(bundle));
Master.get().execute(AlertSkill.class, alertSkill -> alertSkill.stopSkill(bundle));
clearAlarm();
loginManager.logout(new ClovaLogoutCallback() {
@Override
public void onSuccess() {
//1. logout(),会向cic发送一个请求,在response 回包成功时会clearToken
}
@Override
public void onError(@NonNull Throwable throwable) {
ClovaAuthUtil.clearToken(Utils.getContext());
}
});
}
删除设备 :Clova 有个官方的设备管理的App,名字就叫Clova,当通过我们的Companion App完成设备的绑定授权后,在Clova App的设备列表里就可以看到该设备, 也可以删除该设备。 当某个设备被删除后, 机器人端触发一次交互,或者发送某个Event时,会导致DownChannel 断开,错误码是401,Sdk里的逻辑会触发走logout的流程,机器人端的accessToken被清除了,这时我们需要把这个状态同步给UBT后台,后台会解除该设备的绑定,并下发push给Companion App。
具体的实现逻辑见: UBTAuthorizationManager.onLogout。
/**当在Clova App上去删除悟空设备时,CicNetworkClient.interceptResponse()会调用loginManager.logout
,在 AlphaMini App上触发的解绑也会调用loginManager.logout()
* */
@Subscribe(threadMode = ThreadMode.MAIN)
public void onLogout(@NonNull LogoutEvent logoutEvent) {
Logger.debug(TAG, "Logout success");
//logout会触发调用ClovaModule.onLayout(),会stop掉 keywordModule,所以需要重新开启
Completable.timer(500, TimeUnit.MILLISECONDS).observeOn(AndroidSchedulers.mainThread()).subscribe(()->{
if (!isKeywordMgrStarted()) {
Log.d(TAG,"isKeywordMgrStarted -- false");
KeywordModuleHelper.startKeywordModule();
ReflectHelper.setFieldValue(ClovaModule.getInstance(), "startFailedNotLoggedIn", Boolean.TRUE);
}
});
//把解绑事件上报给UBT后台,如果是Clova app触发的解绑,后台需要给AlphaMini下发push,告知解绑事件
if (logoutType != LogoutType.ByUbtApp) {
UbtRestApi.get().unbindRobot().subscribe(new Observer<BaseResponse>() {
@Override
public void onSubscribe(Disposable d) {
Log.i(TAG, "onSubscribe: ");
}
@Override
public void onNext(BaseResponse s) {
Log.i(TAG, "onNext: " + s);
}
@Override
public void onError(Throwable e) {
Log.e(TAG, "onError: " + e.getMessage(), e);
}
@Override
public void onComplete() {
Log.i(TAG, "onComplete: ");
}
});
}
}
Note : 详细的登录授权的逻辑,可以参考Clova_Developer_Guide_For_Partners_v3.41.0_en-KR.pdf 38页
设置发音人
Clova 官方支持多个发音人, AlphaMini需要偏小孩的声音, 需要进行设置。
在机器人端发送SpeechRecognize和SpeechSynthesize event的请求上需要带上设置发音人的context。
我们知道机器人端给Clova Server端发送event的requestBody是基于Http2.0 multipart的格式。
具体的消息格式如下:
![](https://img.haomeiwen.com/i6354276/2c6f126822c4eb0a.png)
一般在第一个MessagePart包含event请求的最主要信息,它包含context
和events
两部分。
-
events :
events
的部分包含此次请求的核心信息,header
部分namespace
和name
字段标识具体的消息类型,payload
部分包含该消息的具体信息。 -
context :
context
带的信息是机器人设备的一些状态信息, 它是数组的结构, 默认包含Alerts.AlertsState
、SpeechSynthesizer.SpeechState
、AudioPlayer.PlaybackState
等,它允许添加自定义的context, 修改发音人就是通过自定义context来实现的。实现逻辑如下(V007就是小朋友的发音,可以改变该值来设置成其他声音):
public class UbtEventContextFactory implements ClovaEventContextProvider.ClovaEventContextFactory {
@Override
public String getNameSpace() {
return "Clova";
}
@Override
public String getName() {
return "Assistant";
}
@Override
public ContextDataModel createContextData() {
return new ContextDataModel(new ContextHeaderDataModel(ClovaNamespace.Clova.getValue(),"Assistant"),
new TtsPayload("V007"));
}
}
接入麦克风阵列 & 唤醒引擎
Clova SDK提供了默认的麦克风的实现与内置的唤醒引擎,同时提供了接口类ClovaAudioCapture
,允许我们自定义麦克风实现类, ClovaCoreApi.Builder提供了replaceClovaAudioCaptureFactory(ClovaAudioCaptureFactory clovaAudioCaptureFactory) replaceClovaKeywordDetectorFactory(ClovaKeywordDetector.Factory clovaKeywordDetectorFactory)
接口,允许我们自实现麦克风和唤醒引擎,并注入到sdk里。
这里涉及到两个ClovaAudioCapture, 一个用于Wakeup,一个用于SpeechRecognize。
·replaceClovaAudioCaptureFactory()·只用于替换用于SpeechRecognize的AudioCapture。
sdk默认的麦克风是调用Android原生的AudioRecorder,我们的机器人的麦克风阵列有专门的sdk,所以需要自实现ClovaAudioCapture。
sdk内置的唤醒引擎,唤醒率较差,无法满足产品需要,所以也需要接入新的。
通过如下代码,完成用于SpeechRecognize的AudioCapture的替换。
it.replaceClovaAudioCaptureFactory(UBTRecognizeFactory())
public class UBTRecognizeFactory implements ClovaAudioCaptureFactory {
@Override
public ClovaAudioCapture create() {
return new UBTRecognizeAudioCapture();
}
}
通过如下代码完成唤醒引擎的替换:
it.replaceClovaKeywordDetectorFactory(UBTKeywordDetectorFactory())
public class UBTKeywordDetectorFactory implements ClovaKeywordDetector.Factory {
@NonNull
@Override
public ClovaKeywordDetector create() {
return new UbtClovaKeywordDetector(new WakeupAudioCaptureFactory().create());
}
}
public class WakeupAudioCaptureFactory {
public UBTWakewordAudioCapture create() {
return new UBTWakewordAudioCapture();
}
}
唤醒逻辑的改造
唤醒后进入识别时依然能唤醒
- Activity进入后台时也能唤醒,
因为sdk主要是面向有屏设备的, 所以有Activity在后台时不能唤醒的限制, 通过重写Application的registerActivityLifecycleCallbacks(), 使sdk对Activity前后台的监听失效。
- 未登录时依然能唤醒
在sdk里已经封装好了在未登录时不初始化唤醒模块的逻辑, 所以需要通过反射来修改sdk的行为。
具体代码见:
UBTCloverSpeechImpl、UBTAuthorizationManager、KeywordModuleHelper。
Notification
这个代表一些定义为系统提示的语音播报,和系统内其他声音能够共存。系统内的流媒体声音,TTS播报,Notification 存在一个前后台的关系。后发的声音,会成为前台(FG),把正在播放的声音压入后台(BG),处于后台的声音音量变小。发出多个Notification时,会排队播放。
AudioLayerManager
Clova SDK通过AudioLayerManager来管理这些声音的前后台逻辑。我们实现的播放器需要 通过他暴露出来的方法设置进去 ClovaInternalModule.getInstance().getAudioLayerManager().setDefaultExternalSpeaker(...)
Audio Layer前后台的监听
Clova SDK中各种声音类型对应一种layer,每个layer的播放器,对于一段音频的播放状态都会通过eventbus发送相应的事件, AudioLayerManager会接收这些事件,来处理layer的前后台状态。
我们通过Java动态代理的方式可以对layer的状态进行监听,每个layer的播放器都实现了ClovaAudioLayerController接口,通过动态代理对 ClovaAudioLayerController 接口内
void goBackground();
void goForeground();
void goForegroundImmediately();
void goBackgroundImmediately();
四个方法的监听,达到对layer前后台状态变更的监听。
实现我们的 NotificationSpeaker
通过继承Clova SDK 内的 AbstractSpeaker 类来实现,我们需要传递相应的初始化参数比如,AudioContentType 和 VolumeType
实现3个关机的方法:
- sendPrepareOfPlayEvent 播放开始, 需要通过eventbus发送 SpeakerEvent.ExternalPrepareEvent 给 AudioLayerManager,以便AudioLayerManager进行前后台逻辑处理,同时可以加入我们自己的一些逻辑
- sendEndOfPlayEvent 播放结束,发送 SpeakerEvent.EndOfExternalSpeakEvent
- sendInterruptionOfPlayEvent 播放被打断,发送 SpeakerEvent.InterruptionOfExternalSpeakEvent
下面是一个实现示例,它可以在Clova没有登录时也能进行播放:
public class UbtNotificationSpeaker extends AbstractSpeaker{
private final static String TAG = "UbtNotificationSpeaker";
MediaPlayer player = null;
public UbtNotificationSpeaker() {
this(ClovaModule.getInstance().getEventBus(), ClovaInternalModule.getInstance().getClovaExecutor(), ClovaInternalModule.getInstance().getClovaMusicPlayerFactory(), ClovaModule.getInstance().getClovaLoginManager(), ClovaModule.getInstance().getSpeechRecognizeManager(), ClovaInternalModule.getInstance().getInternalVolumeManager(), ClovaInternalModule.getInstance().getAudioLayerRule());
}
public UbtNotificationSpeaker(@NonNull EventBus eventBus, @NonNull ClovaExecutor clovaExecutor, @NonNull ClovaMediaPlayer.Factory musicPlayerFactory, @Nullable ClovaLoginManager clovaLoginManager, @NonNull SpeechRecognizeManager speechRecognizeManager, @NonNull InternalVolumeManager internalVolumeManager, @NonNull AudioLayerRule audioLayerRule) {
super(eventBus, clovaExecutor, musicPlayerFactory, AudioContentType.EXTERNAL_SPEAKER, clovaLoginManager, speechRecognizeManager, VolumeType.EXTERNAL, internalVolumeManager, audioLayerRule);
}
@Override
protected void sendPrepareOfPlayEvent(@NonNull UriHolder uriHolder) {
LogUtils.i(TAG, "sendPrepareOfPlayEvent: "+uriHolder);
this.eventBus.post(new SpeakerEvent.ExternalPrepareEvent());
if (uriHolder instanceof NotificationUriHolder) {
NotificationUriHolder notificationUriHolder = (NotificationUriHolder) uriHolder;
if (notificationUriHolder.getTtsListener()!=null){
notificationUriHolder.getTtsListener().onTtsBegin();
}
}
if (uriHolder instanceof NotificationUriHolder && ((NotificationUriHolder) uriHolder).shouldLed()) {
HandlerUtils.runUITask(() -> MouthLedManager.get().notificationMouthLed(),300);
}
}
@Override
protected void sendInterruptionOfPlayEvent(@NonNull UriHolder uriHolder) {
LogUtils.i(TAG, "sendInterruptionOfPlayEvent: "+uriHolder);
this.eventBus.post(new SpeakerEvent.InterruptionOfExternalSpeakEvent(uriHolder.getUri().toString()));
if (uriHolder instanceof NotificationUriHolder) {
NotificationUriHolder notificationUriHolder = (NotificationUriHolder) uriHolder;
if (notificationUriHolder.getTtsListener()!=null){
notificationUriHolder.getTtsListener().onTtsCompleted(-1, "interrupted");
notificationUriHolder.setTtsListener(null);
}
}
}
@Override
protected void sendEndOfPlayEvent(@NonNull UriHolder uriHolder) {
LogUtils.i(TAG, "sendEndOfPlayEvent: "+uriHolder);
this.eventBus.post(new SpeakerEvent.EndOfExternalSpeakEvent(uriHolder.getUri().toString(), this.isItemInQueue()));
if (uriHolder instanceof NotificationUriHolder) {
NotificationUriHolder notificationUriHolder = (NotificationUriHolder) uriHolder;
if (notificationUriHolder.getTtsListener()!=null){
notificationUriHolder.getTtsListener().onTtsCompleted(0, "success");
notificationUriHolder.setTtsListener(null);
}
}
}
/**
* 播放本地tts
* @param name tts名
* @param loop 是否循环
*/
public void play(@NonNull String name, boolean loop, boolean led, ITTsListener listener) {
Uri uri = Uri.fromFile(new File(PropertiesApi.findSystemTTsPath(name)));
play(uri, loop, led, listener);
}
public void play(@NonNull Uri uri, boolean loop, boolean led, ITTsListener listener) {
LogUtils.i(TAG, "play: " + uri + " loop: " + loop + " led:" + led);
NotificationUriHolder uriHolder = new NotificationUriHolder(uri, loop, led);
uriHolder.setTtsListener(listener);
enqueue(uriHolder);
}
@WorkerThread
protected void playOnLogout(final UriHolder... uriHolders) {
if (uriHolders.length <= 0) {
Logger.debug(TAG, "playOnLogout UriHolder path is empty and queue size > " + this.queue.size());
} else {
Logger.debug(TAG, "playOnLogout start path=" + uriHolders[0].getUri());
if (this.isItemInQueue()) {
playLocalTts(uriHolders[0].getUri(), new MediaPlayer.OnPreparedListener() {
@Override
public void onPrepared(MediaPlayer mediaPlayer) {
Log.i(TAG, "onPrepared: ");
sendPrepareOfPlayEvent(uriHolders[0]);
mediaPlayer.start();
}
}, new MediaPlayer.OnCompletionListener() {
@Override
public void onCompletion(MediaPlayer mediaPlayer) {
Log.i(TAG, "onCompletion: ");
cleanupAfterSpeakCompleted(uriHolders);
}
},new MediaPlayer.OnErrorListener(){
@Override
public boolean onError(MediaPlayer mediaPlayer, int i, int i1) {
LogUtils.e(TAG, "playOnLogout playLocalTTs onError: i= " + i + " i1= "+i1 );
cleanupAfterSpeakCompleted(uriHolders);
return false;
}
});
}
}
}
private void playLocalTts(Uri uri, MediaPlayer.OnPreparedListener onPreparedListener , MediaPlayer.OnCompletionListener onCompletionListener, MediaPlayer.OnErrorListener onErrorListener) {
try {
if (null==player){
player = new MediaPlayer();
}
player.reset();
player.setDataSource(Utils.getContext(),uri);
player.setOnPreparedListener(onPreparedListener);
player.setOnCompletionListener(onCompletionListener);
player.setOnErrorListener(onErrorListener);
player.prepareAsync();
} catch (Exception e) {
e.printStackTrace();
if (player != null) {
player.release();
}
}
}
public void enqueue(@NonNull UriHolder uriHolder) {
Logger.debug(TAG, "uri=" + uriHolder.getUri() + " queue size=" + this.queue.size());
if (this.isItemInQueue()) {
this.queue.offer(uriHolder);
Logger.debug(TAG, "add uri=" + uriHolder.getUri() + " queue size=" + this.queue.size() + ", looping=" + uriHolder.shouldLoop());
} else {
this.queue.offer(uriHolder);
this.triggerDequeue();
Logger.debug(TAG, "trigger dequeue uri=" + uriHolder.getUri() + " queue size=" + this.queue.size() + ", looping=" + uriHolder.shouldLoop());
}
}
@AnyThread
protected void triggerDequeue() {
Completable.fromAction(() -> {
UriHolder firstPath = (UriHolder)this.queue.peek();
if (firstPath != null) {
Logger.debug(TAG, "onNext firstPath=" + firstPath.getUri() + " queue size=" + this.queue.size());
if (ClovaLoginModule.getInstance().getLoginManager().isLogin()){
playUri(firstPath.shouldLoop(), firstPath.isAuthenticationRequired(), firstPath);
}else {
playOnLogout(firstPath);
}
} else {
Logger.debug(TAG, "queue.peek() is null, queue size=" + this.queue.size());
}
}).subscribeOn(this.clovaExecutor.getBackgroundScheduler()).doOnError((e) -> {
Logger.warn(TAG, "enqueue onError", e);
}).subscribe();
}
@Override
public void clear() {
Logger.info(TAG, "clear");
super.clear();
}
@Override
public void start() {
super.start();
LogUtils.i(TAG, "start: ");
}
@Override
public void stop() {
super.stop();
LogUtils.i(TAG, "stop: ");
}
public static class NotificationUriHolder implements UriHolder {
.....
}
}
DeviceControl的实现
首先需要 实现 ClovaEventContextProvider.ClovaEventContextFactory 接口, 把机器人的 WiFi,电量,声音大小等各种状态同步给Clova。通过ClovaApi.getClovaEventContextFactories 把这个Factory设置进SDK
public class DeviceStateEventContextFactory implements ClovaEventContextProvider.ClovaEventContextFactory {
public DeviceStateEventContextFactory() {
}
@NonNull
@Override
public String getNameSpace() {
return Device.NameSpace;
}
@NonNull
@Override
public String getName() {
return Device.DeviceStateDataModel.Name;
}
/**
* You can implement specific devices/services dependent logic or exclude specific payload fields
* if they are not suitable for your use case.
*/
@Nullable
@Override
public ContextDataModel createContextData() {
final DeviceInfoController deviceInfoController = ClovaInternalModule.getInstance().getDeviceInfoController();
final Device.Wifi wifi = getWifi(deviceInfoController);
final Device.Cellular cellular = getCellular(deviceInfoController);
final Device.Airplane airplane = getAirplane(deviceInfoController);
final Device.Gps gps = getGps(deviceInfoController);
final Device.FlashLight flashLight = getFlashLight(deviceInfoController);
final Device.Battery battery = getBattery(deviceInfoController);
final Device.EnergySavingMode energySavingMode = getEnergySavingMode(deviceInfoController);
final Device.ScreenBrightness screenBrightness = getScreenBrightness(deviceInfoController);
final Device.SoundMode soundMode = getSoundMode(deviceInfoController);
final Device.Volume volume = getVolume(deviceInfoController);
final Device.Microphone microphone = getMicrophone(deviceInfoController);
final Device.Power power = getPower();
return new ContextDataModel<>(new ContextHeaderDataModel(getNameSpace(),getName()),
new Device.DeviceStateDataModel(getIso8601DateFromMilliSecond(System.currentTimeMillis()),null,wifi,battery,flashLight,gps,soundMode,volume,airplane,energySavingMode,screenBrightness,cellular,power,null,
microphone,null,null,null,null,null,null,null,null));
}
...
然后通过实现一个 UBTDeviceControlManager 来接收SDK的DeviceControl事件,比如音量修改,WiFi开关等,具体实现见git工程源码。
语音交互状态的监听
代码见:SpeechStateMonitor
自定义技能
对比过腾讯叮当、亚马逊Alexa后会发现,这块Naver做的不错。因为像机器人这种产品,需要自定义一些语音指令,可以控制机器人运动,所以需要Clova Server能把识别的指令返回给终端(亚马逊这块支持最差,我们在云端定义的Skill Server 只能返回语音播报的文本或音频的url), 叮当在前期也如同Alexa,后来才支持允许Skill Server能下发自定义指令。
那基于Clova平台,具体如何自定义技能呢?
- 定义各个intent的语料
对Naver来说自定义技能分为硬件设备厂商开发的技能和第三方软件服务商开发的技能。 硬件设备厂商开发的技能一般只适用与对应的硬件设备,第三方服务商开发的技能一般是通用的, 所有的接入Clova的硬件设备(手机、音响、机器人等等)都可以访问。 第三方通用技能,可以在Clova的开放平台上创建技能。 但设备厂商的技能需要把技能的intent语料发给Naver,由他们进行训练。
意图、语料的设计
产品同学负责设计语料,以及标注出slot,提供给Clova进行训练。 Clova最近在开发技能训练平台,目前还未开放给我们。
技能Skill的开发
包含两部分:
- Skill Server的开发
- 机器人端执行技能
** Skill Server 开发 **
类似叮当平台的skill Server开发,逻辑也很简单。Skill server 接收从Clova NLP平台传过来的参数,根据intent/slot组装出一个自定义的Directive,返回给Clova NLP平台。自定义的Directive包含具体的指令以及机器人能处理的参数。
自定义Skill的request/response的数据格式可见前面提到的开发指导文档。
UBT Skill Server代码基于SpringBoot来实现。
代码工程:
git@gitlab.ubt.com:alpha_mini/Naver-Skill-Server.git
** 机器人端技能的执行 **
通过实现一个ClovaServicePlugin,并注入到sdk来实现自定义Directive的处理。
代码见:UBTControlServicePlugin
注意:Namespace/name需要与Skill Server里定义的保持一致。
网友评论