mirror of
https://github.com/yuga-hashimoto/openclaw-assistant.git
synced 2026-08-14 09:47:26 +00:00
Add OpenClaw wake word support and README
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# OpenClaw Assistant
|
||||
|
||||
OpenClaw専用のAndroid音声アシスタントアプリ。
|
||||
|
||||
## 機能
|
||||
|
||||
- 🎤 **ウェイクワード「OpenClaw」** - 音声だけで起動
|
||||
- 🏠 **ホームボタン長押し** - システムアシスタントとして動作
|
||||
- 🔄 **連続会話** - セッションを維持して自然な対話
|
||||
- 🔒 **プライバシー重視** - 設定は暗号化保存
|
||||
|
||||
## セットアップ
|
||||
|
||||
### 1. アプリのインストール
|
||||
|
||||
Android Studioでビルドするか、Releasesからapkをダウンロード。
|
||||
|
||||
### 2. 設定
|
||||
|
||||
1. アプリを開く
|
||||
2. 右上の⚙️から設定画面へ
|
||||
3. 以下を入力:
|
||||
- **Webhook URL** (必須): OpenClawのエンドポイント
|
||||
- **認証トークン** (任意): Bearer認証用
|
||||
- **Picovoice Access Key**: https://console.picovoice.ai で無料取得
|
||||
|
||||
### 3. ウェイクワード「OpenClaw」の設定
|
||||
|
||||
1. [Picovoice Console](https://console.picovoice.ai) にログイン
|
||||
2. **Porcupine** → **Custom Keywords**
|
||||
3. 「OpenClaw」と入力してキーワード作成
|
||||
4. Android用の `.ppn` ファイルをダウンロード
|
||||
5. `app/src/main/assets/openclaw_android.ppn` として配置
|
||||
6. アプリを再ビルド
|
||||
|
||||
### 4. システムアシスタントとして設定(任意)
|
||||
|
||||
1. 端末の設定 → アプリ → デフォルトアプリ → アシスタントアプリ
|
||||
2. 「OpenClaw Assistant」を選択
|
||||
3. ホームボタン長押しで起動可能に
|
||||
|
||||
## OpenClaw側の設定
|
||||
|
||||
### リクエスト形式
|
||||
|
||||
```json
|
||||
POST /your-webhook-endpoint
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <token>
|
||||
|
||||
{
|
||||
"message": "ユーザーの発話テキスト",
|
||||
"session_id": "uuid-xxx-xxx",
|
||||
"user_id": "optional"
|
||||
}
|
||||
```
|
||||
|
||||
### レスポンス形式
|
||||
|
||||
以下のいずれかの形式でOK:
|
||||
|
||||
```json
|
||||
{"response": "応答テキスト"}
|
||||
{"text": "応答テキスト"}
|
||||
{"message": "応答テキスト"}
|
||||
```
|
||||
|
||||
## 技術スタック
|
||||
|
||||
- Kotlin + Jetpack Compose
|
||||
- VoiceInteractionService
|
||||
- Picovoice Porcupine (ホットワード検知)
|
||||
- Android SpeechRecognizer
|
||||
- TextToSpeech
|
||||
- OkHttp + Gson
|
||||
- EncryptedSharedPreferences
|
||||
|
||||
## 必要な権限
|
||||
|
||||
- `RECORD_AUDIO` - 音声認識
|
||||
- `INTERNET` - API通信
|
||||
- `FOREGROUND_SERVICE` - 常時聴取
|
||||
- `POST_NOTIFICATIONS` - 通知表示
|
||||
|
||||
## ライセンス
|
||||
|
||||
MIT License
|
||||
@@ -0,0 +1,19 @@
|
||||
# OpenClaw Wake Word File
|
||||
|
||||
このフォルダに `openclaw_android.ppn` ファイルを配置してください。
|
||||
|
||||
## 作成方法
|
||||
|
||||
1. https://console.picovoice.ai にアクセス
|
||||
2. アカウント作成(無料)
|
||||
3. **Porcupine** → **Custom Keywords** を選択
|
||||
4. 「OpenClaw」と入力してキーワードを作成
|
||||
5. **Android** プラットフォームを選択
|
||||
6. `.ppn` ファイルをダウンロード
|
||||
7. ファイル名を `openclaw_android.ppn` に変更
|
||||
8. このフォルダに配置
|
||||
|
||||
## 注意
|
||||
|
||||
- カスタムキーワードファイルがない場合、フォールバックとして「Porcupine」が使用されます
|
||||
- Picovoice Access Keyは別途設定画面で入力が必要です
|
||||
@@ -179,8 +179,8 @@ fun MainScreen(
|
||||
// ホットワード
|
||||
ActionCard(
|
||||
icon = Icons.Default.Mic,
|
||||
title = "ウェイクワード「Porcupine」",
|
||||
description = if (hotwordEnabled) "常時聴取中" else "タップで有効化",
|
||||
title = "ウェイクワード「OpenClaw」",
|
||||
description = if (hotwordEnabled) "「OpenClaw」と呼んでください" else "タップで有効化",
|
||||
showSwitch = true,
|
||||
switchValue = hotwordEnabled,
|
||||
onSwitchChange = { enabled ->
|
||||
|
||||
@@ -21,10 +21,12 @@ import com.openclaw.assistant.speech.TTSManager
|
||||
import ai.picovoice.porcupine.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* ホットワード検知サービス
|
||||
* バックグラウンドで常時聴取し、ウェイクワードを検知したら音声認識開始
|
||||
* バックグラウンドで常時聴取し、ウェイクワード「OpenClaw」を検知したら音声認識開始
|
||||
*/
|
||||
class HotwordService : Service() {
|
||||
|
||||
@@ -32,6 +34,9 @@ class HotwordService : Service() {
|
||||
private const val TAG = "HotwordService"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val CHANNEL_ID = "hotword_channel"
|
||||
|
||||
// カスタムウェイクワードファイル名(assetsに配置)
|
||||
private const val WAKE_WORD_FILE = "openclaw_android.ppn"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, HotwordService::class.java)
|
||||
@@ -114,7 +119,7 @@ class HotwordService : Service() {
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.notification_title))
|
||||
.setContentText(getString(R.string.notification_text))
|
||||
.setContentText("「OpenClaw」と呼んでください")
|
||||
.setSmallIcon(R.drawable.ic_mic)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
@@ -122,6 +127,33 @@ class HotwordService : Service() {
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* カスタムキーワードファイルをassetsから内部ストレージにコピー
|
||||
*/
|
||||
private fun copyKeywordFile(): String? {
|
||||
return try {
|
||||
val outputFile = File(filesDir, WAKE_WORD_FILE)
|
||||
|
||||
// 既にコピー済みならスキップ
|
||||
if (outputFile.exists()) {
|
||||
return outputFile.absolutePath
|
||||
}
|
||||
|
||||
// assetsからコピー
|
||||
assets.open(WAKE_WORD_FILE).use { input ->
|
||||
FileOutputStream(outputFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Keyword file copied to: ${outputFile.absolutePath}")
|
||||
outputFile.absolutePath
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to copy keyword file", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun startHotwordDetection() {
|
||||
val accessKey = settings.picovoiceAccessKey
|
||||
if (accessKey.isBlank()) {
|
||||
@@ -131,23 +163,54 @@ class HotwordService : Service() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Porcupine(ホットワード検知エンジン)を初期化
|
||||
// 組み込みキーワード "Hey Google" の代わりに "Porcupine" を使用
|
||||
// カスタムキーワードを使う場合は .ppn ファイルをassetsに配置
|
||||
porcupineManager = PorcupineManager.Builder()
|
||||
// カスタムキーワードファイルをコピー
|
||||
val keywordPath = copyKeywordFile()
|
||||
|
||||
val builder = PorcupineManager.Builder()
|
||||
.setAccessKey(accessKey)
|
||||
.setKeyword(Porcupine.BuiltInKeyword.PORCUPINE) // "Porcupine" がウェイクワード
|
||||
.setSensitivity(0.7f)
|
||||
.build(this) { keywordIndex ->
|
||||
Log.d(TAG, "Hotword detected! Index: $keywordIndex")
|
||||
onHotwordDetected()
|
||||
}
|
||||
|
||||
if (keywordPath != null) {
|
||||
// カスタムキーワード「OpenClaw」を使用
|
||||
Log.d(TAG, "Using custom keyword: OpenClaw")
|
||||
builder.setKeywordPath(keywordPath)
|
||||
} else {
|
||||
// フォールバック: ビルトインキーワード「Porcupine」を使用
|
||||
Log.w(TAG, "Custom keyword not found, falling back to 'Porcupine'")
|
||||
builder.setKeyword(Porcupine.BuiltInKeyword.PORCUPINE)
|
||||
}
|
||||
|
||||
porcupineManager = builder.build(this) { keywordIndex ->
|
||||
Log.d(TAG, "Hotword 'OpenClaw' detected!")
|
||||
onHotwordDetected()
|
||||
}
|
||||
|
||||
porcupineManager?.start()
|
||||
Log.d(TAG, "Hotword detection started")
|
||||
|
||||
} catch (e: PorcupineException) {
|
||||
Log.e(TAG, "Failed to start Porcupine", e)
|
||||
// カスタムキーワードで失敗した場合、ビルトインで再試行
|
||||
tryFallbackKeyword(accessKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryFallbackKeyword(accessKey: String) {
|
||||
try {
|
||||
Log.d(TAG, "Trying fallback keyword 'Porcupine'")
|
||||
porcupineManager = PorcupineManager.Builder()
|
||||
.setAccessKey(accessKey)
|
||||
.setKeyword(Porcupine.BuiltInKeyword.PORCUPINE)
|
||||
.setSensitivity(0.7f)
|
||||
.build(this) { keywordIndex ->
|
||||
Log.d(TAG, "Hotword detected (fallback)!")
|
||||
onHotwordDetected()
|
||||
}
|
||||
|
||||
porcupineManager?.start()
|
||||
Log.d(TAG, "Fallback hotword detection started")
|
||||
} catch (e: PorcupineException) {
|
||||
Log.e(TAG, "Fallback also failed", e)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
@@ -169,13 +232,21 @@ class HotwordService : Service() {
|
||||
// ホットワード検知を一時停止
|
||||
porcupineManager?.stop()
|
||||
|
||||
// 確認音(オプション)
|
||||
// playConfirmationSound()
|
||||
// 確認音を鳴らす(オプション)
|
||||
playConfirmationBeep()
|
||||
|
||||
// 音声認識開始
|
||||
startCommandListening()
|
||||
}
|
||||
|
||||
private fun playConfirmationBeep() {
|
||||
// 短いビープ音で応答(TTSで代用)
|
||||
scope.launch {
|
||||
// 「はい」と短く応答
|
||||
ttsManager.speak("はい")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCommandListening() {
|
||||
scope.launch {
|
||||
var recognizedText: String? = null
|
||||
@@ -206,8 +277,10 @@ class HotwordService : Service() {
|
||||
private fun sendToOpenClaw(message: String) {
|
||||
if (!settings.isConfigured()) {
|
||||
Log.e(TAG, "Webhook not configured")
|
||||
ttsManager.speakQueued("設定が必要です")
|
||||
resumeHotwordDetection()
|
||||
scope.launch {
|
||||
ttsManager.speak("設定が必要です")
|
||||
resumeHotwordDetection()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -230,7 +303,7 @@ class HotwordService : Service() {
|
||||
},
|
||||
onFailure = { error ->
|
||||
Log.e(TAG, "API error", error)
|
||||
ttsManager.speakQueued("エラーが発生しました")
|
||||
ttsManager.speak("エラーが発生しました")
|
||||
resumeHotwordDetection()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<string name="notification_channel_name">Hotword Detection</string>
|
||||
<string name="notification_channel_description">常時音声検知サービス</string>
|
||||
<string name="notification_title">OpenClaw Assistant</string>
|
||||
<string name="notification_text">ウェイクワードを待機中…</string>
|
||||
<string name="notification_text">「OpenClaw」と呼んでください</string>
|
||||
<string name="settings_title">設定</string>
|
||||
<string name="webhook_url_label">Webhook URL</string>
|
||||
<string name="webhook_url_hint">https://your-openclaw-endpoint.com/webhook</string>
|
||||
|
||||
Reference in New Issue
Block a user