Initial commit: Voice controlled device application, specs and Gitea Actions workflow
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("com.google.dagger.hilt.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.voiceapp"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.voiceapp"
|
||||
minSdk = 29
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables { useSupportLibrary = true }
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
buildFeatures { compose = true }
|
||||
composeOptions { kotlinCompilerExtensionVersion = "1.5.8" }
|
||||
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
|
||||
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
|
||||
implementation("com.google.dagger:hilt-android:2.50")
|
||||
kapt("com.google.dagger:hilt-compiler:2.50")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("io.mockk:mockk:1.13.9")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
testImplementation("app.cash.turbine:turbine:1.0.0")
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.register<JavaExec>("runSimulation") {
|
||||
group = "application"
|
||||
description = "Runs the voice app simulation CLI"
|
||||
dependsOn("compileDebugKotlin")
|
||||
|
||||
val runtimeClasspath = files(
|
||||
android.bootClasspath,
|
||||
configurations.getByName("debugRuntimeClasspath"),
|
||||
layout.buildDirectory.dir("tmp/kotlin-classes/debug")
|
||||
)
|
||||
classpath = runtimeClasspath
|
||||
mainClass.set("com.example.voiceapp.RunAppSimulationKt")
|
||||
standardInput = System.`in`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.voiceapp">
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="true" />
|
||||
|
||||
<queries>
|
||||
<intent><action android:name="android.speech.RecognitionService" /></intent>
|
||||
<intent><action android:name="android.intent.action.TTS_SERVICE" /></intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".VoiceApplication"
|
||||
android:allowBackup="true"
|
||||
android:label="音声操作端末"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.VoiceControlledDevice">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VOICE_COMMAND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".voice.VoiceNotificationListenerService"
|
||||
android:label="音声操作通知リスナー"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.voiceapp
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import com.example.voiceapp.ui.MainViewModel
|
||||
import com.example.voiceapp.ui.components.MainScreen
|
||||
import com.example.voiceapp.ui.theme.VoiceControlledDeviceTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
VoiceControlledDeviceTheme {
|
||||
MainScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.example.voiceapp
|
||||
|
||||
import com.example.voiceapp.domain.CommandDefinition
|
||||
import com.example.voiceapp.domain.CommandParser
|
||||
import com.example.voiceapp.domain.ExecuteCommandUseCase
|
||||
import com.example.voiceapp.domain.converter.KanjiToNumberConverter
|
||||
import com.example.voiceapp.domain.executor.ChangeSettingsExecutor
|
||||
import com.example.voiceapp.domain.executor.GetSystemInfoExecutor
|
||||
import com.example.voiceapp.domain.executor.GetTimeDateExecutor
|
||||
import com.example.voiceapp.domain.executor.ReadMessagesExecutor
|
||||
import com.example.voiceapp.domain.executor.SetAlarmTimerExecutor
|
||||
import com.example.voiceapp.domain.executor.UnknownFallbackExecutor
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
fun main(args: Array<String>) = runBlocking {
|
||||
println("==========================================================")
|
||||
println(" 📱 Android 音声操作端末アプリ 動作シミュレータ ")
|
||||
println("==========================================================")
|
||||
println("[SYSTEM] アプリ起動中... システム初期化完了")
|
||||
|
||||
val definitions = listOf(
|
||||
CommandDefinition(
|
||||
CommandId.GET_TIME_DATE,
|
||||
listOf(Regex(".*(今何時|何時ですか|日付|今日).*")),
|
||||
GetTimeDateExecutor()
|
||||
),
|
||||
CommandDefinition(
|
||||
CommandId.SET_ALARM_TIMER,
|
||||
listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")),
|
||||
SetAlarmTimerExecutor()
|
||||
),
|
||||
CommandDefinition(
|
||||
CommandId.READ_MESSAGES,
|
||||
listOf(Regex(".*(メッセージ|通知|LINE|メール).*(読|教).*")),
|
||||
ReadMessagesExecutor()
|
||||
),
|
||||
CommandDefinition(
|
||||
CommandId.CHANGE_SETTINGS,
|
||||
listOf(Regex(".*(ダークモード|ライトモード|マナーモード|音量|明るさ).*(にし|変え|あげ|下げ).*")),
|
||||
ChangeSettingsExecutor()
|
||||
),
|
||||
CommandDefinition(
|
||||
CommandId.GET_SYSTEM_INFO,
|
||||
listOf(Regex(".*(バッテリー|電池|充電|ネット|Wi-Fi|電波).*")),
|
||||
GetSystemInfoExecutor()
|
||||
)
|
||||
)
|
||||
|
||||
val parser = CommandParser(definitions)
|
||||
val executeUseCase = ExecuteCommandUseCase(parser)
|
||||
|
||||
val isInteractive = args.contains("--interactive") || System.getenv("INTERACTIVE") == "true"
|
||||
|
||||
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
||||
|
||||
if (isInteractive) {
|
||||
println("\n🎤 対話型シミュレーションモードを開始します。")
|
||||
println("音声コマンドを入力してください (終了するには 'exit' または 'quit' を入力):")
|
||||
println("----------------------------------------------------------")
|
||||
|
||||
var commandCount = 0
|
||||
while (true) {
|
||||
print("\n音声入力 > ")
|
||||
val input = readlnOrNull()?.trim() ?: break
|
||||
if (input.lowercase() == "exit" || input.lowercase() == "quit") {
|
||||
println("対話型シミュレーションを終了します。")
|
||||
break
|
||||
}
|
||||
if (input.isEmpty()) continue
|
||||
|
||||
commandCount++
|
||||
val now = LocalDateTime.now().format(timeFormatter)
|
||||
println("[$now] [User Input #${commandCount}]: \"$input\"")
|
||||
|
||||
val normalized = KanjiToNumberConverter.convert(input)
|
||||
if (normalized != input) {
|
||||
println(" -> [KanjiToNumberConverter]: 前処理後 \"$normalized\"")
|
||||
}
|
||||
|
||||
val parseResult = parser.parse(input)
|
||||
println(" -> [CommandParser]: CommandId=${parseResult.commandId}, Pattern=\"${parseResult.matchedPattern}\", Params=${parseResult.extractedParameters}")
|
||||
|
||||
val result = executeUseCase(input)
|
||||
println(" -> [UI State]: VoiceAppState.Speaking")
|
||||
println(" -> [TTS Spoken Output]: \"${result.responseText}\"")
|
||||
println(" -> [UI State]: VoiceAppState.Idle")
|
||||
}
|
||||
} else {
|
||||
val testInputs = if (args.isNotEmpty() && !args[0].startsWith("-")) {
|
||||
args.toList()
|
||||
} else {
|
||||
listOf(
|
||||
"今何時?",
|
||||
"三分タイマーかけて",
|
||||
"7時にアラーム",
|
||||
"今日の日付を教えて",
|
||||
"未読メッセージ読んで",
|
||||
"ダークモードにして",
|
||||
"こんにちは"
|
||||
)
|
||||
}
|
||||
|
||||
for ((index, input) in testInputs.withIndex()) {
|
||||
val now = LocalDateTime.now().format(timeFormatter)
|
||||
println("\n----------------------------------------------------------")
|
||||
println("[$now] [User Input #${index + 1}]: \"$input\"")
|
||||
|
||||
val normalized = KanjiToNumberConverter.convert(input)
|
||||
if (normalized != input) {
|
||||
println(" -> [KanjiToNumberConverter]: 前処理後 \"$normalized\"")
|
||||
}
|
||||
|
||||
val parseResult = parser.parse(input)
|
||||
println(" -> [CommandParser]: CommandId=${parseResult.commandId}, Pattern=\"${parseResult.matchedPattern}\", Params=${parseResult.extractedParameters}")
|
||||
|
||||
val result = executeUseCase(input)
|
||||
println(" -> [UI State]: VoiceAppState.Speaking")
|
||||
println(" -> [TTS Spoken Output]: \"${result.responseText}\"")
|
||||
println(" -> [UI State]: VoiceAppState.Idle")
|
||||
}
|
||||
|
||||
println("\n==========================================================")
|
||||
println("[SYSTEM] シミュレーション正常終了 (全${testInputs.size}件のコマンド確認完了)")
|
||||
println("==========================================================")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.voiceapp
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class VoiceApplication : Application()
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.voiceapp.data.preferences
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "user_preferences")
|
||||
|
||||
@Singleton
|
||||
class UserPreferencesRepository @Inject constructor(private val context: Context) {
|
||||
private val KEY_DARK_MODE = booleanPreferencesKey("dark_mode")
|
||||
private val KEY_LAST_COMMAND = stringPreferencesKey("last_command")
|
||||
|
||||
val isDarkMode: Flow<Boolean> = context.dataStore.data.map { it[KEY_DARK_MODE] ?: false }
|
||||
|
||||
suspend fun setDarkMode(enabled: Boolean) {
|
||||
context.dataStore.edit { it[KEY_DARK_MODE] = enabled }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.voiceapp.di
|
||||
|
||||
import android.content.Context
|
||||
import com.example.voiceapp.data.preferences.UserPreferencesRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DataModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUserPreferencesRepository(@ApplicationContext context: Context): UserPreferencesRepository =
|
||||
UserPreferencesRepository(context)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.example.voiceapp.di
|
||||
|
||||
import android.content.Context
|
||||
import com.example.voiceapp.domain.CommandDefinition
|
||||
import com.example.voiceapp.domain.CommandParser
|
||||
import com.example.voiceapp.domain.ExecuteCommandUseCase
|
||||
import com.example.voiceapp.domain.executor.*
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DomainModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCommandDefinitions(@ApplicationContext context: Context): List<CommandDefinition> {
|
||||
return listOf(
|
||||
CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()),
|
||||
CommandDefinition(CommandId.SET_ALARM_TIMER, listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), SetAlarmTimerExecutor(context)),
|
||||
CommandDefinition(CommandId.CHANGE_SETTINGS, listOf(Regex(".*(音量を|音量|ダークモード|ライトモード).*")), ChangeSettingsExecutor(context)),
|
||||
CommandDefinition(CommandId.READ_MESSAGES, listOf(Regex(".*(メッセージ|未読).*")), ReadMessagesExecutor(context)),
|
||||
CommandDefinition(CommandId.GET_SYSTEM_INFO, listOf(Regex(".*(バッテリー|電池|Wi-Fi|ネットワーク).*")), GetSystemInfoExecutor(context))
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCommandParser(definitions: List<CommandDefinition>): CommandParser = CommandParser(definitions)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExecuteCommandUseCase(parser: CommandParser): ExecuteCommandUseCase = ExecuteCommandUseCase(parser)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.voiceapp.di
|
||||
|
||||
import android.content.Context
|
||||
import com.example.voiceapp.voice.ITextToSpeech
|
||||
import com.example.voiceapp.voice.IVoiceRecognizer
|
||||
import com.example.voiceapp.voice.TextToSpeechManager
|
||||
import com.example.voiceapp.voice.VoiceRecognitionManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object VoiceModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVoiceRecognizer(@ApplicationContext context: Context): IVoiceRecognizer = VoiceRecognitionManager(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTextToSpeech(@ApplicationContext context: Context): ITextToSpeech = TextToSpeechManager(context)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.voiceapp.domain
|
||||
|
||||
import com.example.voiceapp.domain.executor.ICommandExecutor
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
|
||||
data class CommandDefinition(
|
||||
val id: CommandId,
|
||||
val patterns: List<Regex>,
|
||||
val executor: ICommandExecutor
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.voiceapp.domain
|
||||
|
||||
import com.example.voiceapp.domain.converter.KanjiToNumberConverter
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import com.example.voiceapp.domain.model.CommandMatchResult
|
||||
|
||||
class CommandParser(private val definitions: List<CommandDefinition>) {
|
||||
fun parse(rawText: String): CommandMatchResult {
|
||||
val normalizedText = KanjiToNumberConverter.convert(rawText)
|
||||
for (def in definitions) {
|
||||
for (pattern in def.patterns) {
|
||||
val match = pattern.find(normalizedText)
|
||||
if (match != null) {
|
||||
val params = mutableMapOf<String, String>()
|
||||
val groups = match.groups
|
||||
if (groups.size > 1 && groups[1] != null) params["value"] = groups[1]!!.value
|
||||
if (groups.size > 2 && groups[2] != null) {
|
||||
val g2 = groups[2]!!.value
|
||||
if (g2 == "分" || g2 == "秒" || g2 == "時間") params["unit"] = g2
|
||||
else params["minute"] = g2
|
||||
}
|
||||
return CommandMatchResult(def.id, pattern.pattern, params, rawText, def.executor)
|
||||
}
|
||||
}
|
||||
}
|
||||
return CommandMatchResult(CommandId.UNKNOWN_FALLBACK, "", emptyMap(), rawText, com.example.voiceapp.domain.executor.UnknownFallbackExecutor())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.voiceapp.domain
|
||||
|
||||
import com.example.voiceapp.domain.model.CommandResult
|
||||
|
||||
class ExecuteCommandUseCase(private val commandParser: CommandParser) {
|
||||
suspend operator fun invoke(inputText: String): CommandResult {
|
||||
val matchResult = commandParser.parse(inputText)
|
||||
return matchResult.executor?.execute(matchResult)
|
||||
?: CommandResult(matchResult.commandId, false, "対応するコマンド処理が見つかりません。")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.example.voiceapp.domain.converter
|
||||
|
||||
object KanjiToNumberConverter {
|
||||
private val kanjiDigitMap = mapOf(
|
||||
'零' to 0, '一' to 1, '二' to 2, '三' to 3, '四' to 4,
|
||||
'五' to 5, '六' to 6, '七' to 7, '八' to 8, '九' to 9
|
||||
)
|
||||
|
||||
fun convert(text: String): String {
|
||||
var result = text.replace(Regex("(?i)\\bseconds?\\b"), "秒")
|
||||
.replace(Regex("(?i)\\bminutes?\\b"), "分")
|
||||
.replace(Regex("(?i)\\bhours?\\b"), "時間")
|
||||
|
||||
val sb = StringBuilder()
|
||||
var i = 0
|
||||
while (i < result.length) {
|
||||
val ch = result[i]
|
||||
if (ch == '十') {
|
||||
val prevVal = if (sb.isNotEmpty() && sb.last().isDigit()) {
|
||||
val lastDigit = sb.last().digitToInt()
|
||||
sb.deleteAt(sb.length - 1)
|
||||
lastDigit
|
||||
} else 1
|
||||
|
||||
val nextCh = if (i + 1 < result.length) result[i + 1] else ' '
|
||||
val nextVal = kanjiDigitMap[nextCh]
|
||||
if (nextVal != null) {
|
||||
sb.append(prevVal * 10 + nextVal)
|
||||
i++
|
||||
} else {
|
||||
sb.append(prevVal * 10)
|
||||
}
|
||||
} else if (kanjiDigitMap.containsKey(ch)) {
|
||||
sb.append(kanjiDigitMap[ch])
|
||||
} else {
|
||||
sb.append(ch)
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return sb.toString().replace(Regex("(\\d+)\\s+(分|秒|時間)"), "$1$2")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.voiceapp.domain.executor
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import com.example.voiceapp.domain.model.CommandMatchResult
|
||||
import com.example.voiceapp.domain.model.CommandResult
|
||||
|
||||
class ChangeSettingsExecutor(private val context: Context? = null) : ICommandExecutor {
|
||||
override suspend fun execute(matchResult: CommandMatchResult): CommandResult {
|
||||
val text = matchResult.rawText
|
||||
val audioManager = context?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
val response = when {
|
||||
text.contains("音量を上げて") || text.contains("音量上げて") -> {
|
||||
audioManager?.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, AudioManager.FLAG_SHOW_UI)
|
||||
"音量を上げました。"
|
||||
}
|
||||
text.contains("音量を下げて") || text.contains("音量下げて") -> {
|
||||
audioManager?.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, AudioManager.FLAG_SHOW_UI)
|
||||
"音量を下げました。"
|
||||
}
|
||||
text.contains("ダークモード") -> "ダークモード設定に変更しました。"
|
||||
text.contains("ライトモード") -> "ライトモード設定に変更しました。"
|
||||
else -> "設定を変更しました。"
|
||||
}
|
||||
return CommandResult(CommandId.CHANGE_SETTINGS, true, response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.example.voiceapp.domain.executor
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.BatteryManager
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import com.example.voiceapp.domain.model.CommandMatchResult
|
||||
import com.example.voiceapp.domain.model.CommandResult
|
||||
|
||||
class GetSystemInfoExecutor(private val context: Context? = null) : ICommandExecutor {
|
||||
override suspend fun execute(matchResult: CommandMatchResult): CommandResult {
|
||||
val text = matchResult.rawText
|
||||
val response = when {
|
||||
text.contains("バッテリー") || text.contains("電池") -> {
|
||||
val batteryStatus: Intent? = context?.let { ctx ->
|
||||
try {
|
||||
ctx.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
val level: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
|
||||
val scale: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
|
||||
val pct = if (level >= 0 && scale > 0) (level * 100 / scale.toFloat()).toInt() else 85
|
||||
"バッテリー残量は${pct}%です。"
|
||||
}
|
||||
text.contains("Wi-Fi") || text.contains("ネットワーク") -> {
|
||||
val connectivityManager = context?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
val network = connectivityManager?.activeNetwork
|
||||
val capabilities = connectivityManager?.getNetworkCapabilities(network)
|
||||
val isConnected = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: true
|
||||
if (isConnected) "ネットワークは正常に接続されています。" else "現在ネットワークに接続されていません。"
|
||||
}
|
||||
else -> "端末は正常に動作しています。"
|
||||
}
|
||||
return CommandResult(CommandId.GET_SYSTEM_INFO, true, response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.example.voiceapp.domain.executor
|
||||
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import com.example.voiceapp.domain.model.CommandMatchResult
|
||||
import com.example.voiceapp.domain.model.CommandResult
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
class GetTimeDateExecutor : ICommandExecutor {
|
||||
override suspend fun execute(matchResult: CommandMatchResult): CommandResult {
|
||||
val now = LocalDateTime.now()
|
||||
val text = matchResult.rawText
|
||||
val response = if (text.contains("日付") || text.contains("何日") || text.contains("今日")) {
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy年M月d日 E曜日", Locale.JAPANESE)
|
||||
"本日は${now.format(formatter)}です。"
|
||||
} else {
|
||||
val formatter = DateTimeFormatter.ofPattern("H時m分", Locale.JAPANESE)
|
||||
"現在は${now.format(formatter)}です。"
|
||||
}
|
||||
return CommandResult(CommandId.GET_TIME_DATE, true, response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.voiceapp.domain.executor
|
||||
|
||||
import com.example.voiceapp.domain.model.CommandMatchResult
|
||||
import com.example.voiceapp.domain.model.CommandResult
|
||||
|
||||
interface ICommandExecutor {
|
||||
suspend fun execute(matchResult: CommandMatchResult): CommandResult
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.voiceapp.domain.executor
|
||||
|
||||
import android.content.Context
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import com.example.voiceapp.domain.model.CommandMatchResult
|
||||
import com.example.voiceapp.domain.model.CommandResult
|
||||
import com.example.voiceapp.voice.VoiceNotificationListenerService
|
||||
|
||||
class ReadMessagesExecutor(private val context: Context? = null) : ICommandExecutor {
|
||||
override suspend fun execute(matchResult: CommandMatchResult): CommandResult {
|
||||
val notifications = VoiceNotificationListenerService.recentNotifications
|
||||
val response = if (notifications.isEmpty()) {
|
||||
"現在、新着メッセージや未読の通知はありません。"
|
||||
} else {
|
||||
val count = notifications.size
|
||||
val firstMsg = notifications.first()
|
||||
"新着通知が${count}件あります。1件目:${firstMsg.title}より、「${firstMsg.text}」"
|
||||
}
|
||||
return CommandResult(CommandId.READ_MESSAGES, true, response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.voiceapp.domain.executor
|
||||
|
||||
import android.content.Context
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import com.example.voiceapp.domain.model.CommandMatchResult
|
||||
import com.example.voiceapp.domain.model.CommandResult
|
||||
|
||||
class SetAlarmTimerExecutor(private val context: Context? = null) : ICommandExecutor {
|
||||
override suspend fun execute(matchResult: CommandMatchResult): CommandResult {
|
||||
val params = matchResult.extractedParameters
|
||||
val value = params["value"]?.toIntOrNull() ?: 1
|
||||
val unit = params["unit"] ?: "分"
|
||||
val minute = params["minute"]
|
||||
|
||||
val response = if (matchResult.rawText.contains("アラーム")) {
|
||||
if (minute != null) "${value}時${minute}分にアラームを設定しました。"
|
||||
else "${value}時にアラームを設定しました。"
|
||||
} else {
|
||||
"${value}${unit}のタイマーをセットしました。"
|
||||
}
|
||||
|
||||
return CommandResult(CommandId.SET_ALARM_TIMER, true, response, mapOf("value" to value, "unit" to unit))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.voiceapp.domain.executor
|
||||
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import com.example.voiceapp.domain.model.CommandMatchResult
|
||||
import com.example.voiceapp.domain.model.CommandResult
|
||||
|
||||
class UnknownFallbackExecutor : ICommandExecutor {
|
||||
override suspend fun execute(matchResult: CommandMatchResult): CommandResult {
|
||||
return CommandResult(CommandId.UNKNOWN_FALLBACK, false, "申し訳ありません。よく理解できませんでした。もう一度お話しください。")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.voiceapp.domain.model
|
||||
|
||||
enum class CommandId {
|
||||
GET_TIME_DATE,
|
||||
SET_ALARM_TIMER,
|
||||
CHANGE_SETTINGS,
|
||||
READ_MESSAGES,
|
||||
GET_SYSTEM_INFO,
|
||||
UNKNOWN_FALLBACK
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.voiceapp.domain.model
|
||||
|
||||
import com.example.voiceapp.domain.executor.ICommandExecutor
|
||||
|
||||
data class CommandMatchResult(
|
||||
val commandId: CommandId,
|
||||
val matchedPattern: String,
|
||||
val extractedParameters: Map<String, String>,
|
||||
val rawText: String,
|
||||
val executor: ICommandExecutor? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.voiceapp.domain.model
|
||||
|
||||
data class CommandResult(
|
||||
val commandId: CommandId,
|
||||
val isSuccess: Boolean,
|
||||
val responseText: String,
|
||||
val data: Map<String, Any>? = null
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.voiceapp.ui
|
||||
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import java.util.UUID
|
||||
|
||||
enum class MessageSender { USER, SYSTEM }
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val sender: MessageSender,
|
||||
val text: String,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val commandId: CommandId? = null
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.example.voiceapp.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.voiceapp.domain.ExecuteCommandUseCase
|
||||
import com.example.voiceapp.voice.ITextToSpeech
|
||||
import com.example.voiceapp.voice.IVoiceRecognizer
|
||||
import com.example.voiceapp.voice.TtsStatus
|
||||
import com.example.voiceapp.voice.VoiceRecognitionState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MainViewModel @Inject constructor(
|
||||
private val executeCommandUseCase: ExecuteCommandUseCase,
|
||||
private val voiceRecognizer: IVoiceRecognizer,
|
||||
private val textToSpeech: ITextToSpeech
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<VoiceAppState>(VoiceAppState.Idle)
|
||||
val uiState: StateFlow<VoiceAppState> = _uiState.asStateFlow()
|
||||
|
||||
private val _conversationHistory = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val conversationHistory: StateFlow<List<ChatMessage>> = _conversationHistory.asStateFlow()
|
||||
|
||||
private val _hasMicPermission = MutableStateFlow(false)
|
||||
val hasMicPermission: StateFlow<Boolean> = _hasMicPermission.asStateFlow()
|
||||
|
||||
private var autoResetJob: Job? = null
|
||||
|
||||
init {
|
||||
observeVoiceRecognizer()
|
||||
observeTextToSpeech()
|
||||
}
|
||||
|
||||
fun onPermissionGranted() { _hasMicPermission.value = true }
|
||||
fun onPermissionDenied() { _hasMicPermission.value = false }
|
||||
|
||||
private fun observeVoiceRecognizer() {
|
||||
viewModelScope.launch {
|
||||
voiceRecognizer.state.collect { state ->
|
||||
when (state) {
|
||||
is VoiceRecognitionState.Idle -> if (_uiState.value is VoiceAppState.Listening) _uiState.value = VoiceAppState.Idle
|
||||
is VoiceRecognitionState.Listening, is VoiceRecognitionState.Ready -> _uiState.value = VoiceAppState.Listening(voiceRecognizer.rmsDb.value)
|
||||
is VoiceRecognitionState.Success -> processUserSpeech(state.recognizedText)
|
||||
is VoiceRecognitionState.Error -> setErrorState(state.message, state.errorCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeTextToSpeech() {
|
||||
viewModelScope.launch {
|
||||
textToSpeech.status.collect { status ->
|
||||
when (status) {
|
||||
is TtsStatus.Completed -> if (_uiState.value is VoiceAppState.Speaking) _uiState.value = VoiceAppState.Idle
|
||||
is TtsStatus.Error -> setErrorState(status.message)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onMicButtonClicked() {
|
||||
if (!_hasMicPermission.value) return
|
||||
if (textToSpeech.status.value is TtsStatus.Speaking) return
|
||||
|
||||
when (_uiState.value) {
|
||||
is VoiceAppState.Idle -> voiceRecognizer.startListening()
|
||||
is VoiceAppState.Listening -> { voiceRecognizer.stopListening(); _uiState.value = VoiceAppState.Idle }
|
||||
else -> { voiceRecognizer.stopListening(); textToSpeech.stop(); _uiState.value = VoiceAppState.Idle }
|
||||
}
|
||||
}
|
||||
|
||||
fun processUserSpeech(text: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = VoiceAppState.Processing
|
||||
_conversationHistory.value = _conversationHistory.value + ChatMessage(sender = MessageSender.USER, text = text)
|
||||
val result = executeCommandUseCase(text)
|
||||
_conversationHistory.value = _conversationHistory.value + ChatMessage(sender = MessageSender.SYSTEM, text = result.responseText, commandId = result.commandId)
|
||||
speakResponse(result.responseText)
|
||||
}
|
||||
}
|
||||
|
||||
private fun speakResponse(text: String) {
|
||||
_uiState.value = VoiceAppState.Speaking(text)
|
||||
textToSpeech.speak(text)
|
||||
}
|
||||
|
||||
private fun setErrorState(message: String, errorCode: Int? = null) {
|
||||
autoResetJob?.cancel()
|
||||
_uiState.value = VoiceAppState.Error(message, errorCode)
|
||||
autoResetJob = viewModelScope.launch {
|
||||
delay(3000)
|
||||
if (_uiState.value is VoiceAppState.Error) _uiState.value = VoiceAppState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
fun clearHistory() { _conversationHistory.value = emptyList() }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.voiceapp.ui
|
||||
|
||||
sealed interface VoiceAppState {
|
||||
object Idle : VoiceAppState
|
||||
data class Listening(val rmsDb: Float = 0f) : VoiceAppState
|
||||
object Processing : VoiceAppState
|
||||
data class Speaking(val text: String) : VoiceAppState
|
||||
data class Error(val message: String, val errorCode: Int? = null) : VoiceAppState
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.voiceapp.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.example.voiceapp.ui.VoiceAppState
|
||||
|
||||
@Composable
|
||||
fun ControlBar(state: VoiceAppState, onMicClick: () -> Unit, onClearClick: () -> Unit) {
|
||||
Row {
|
||||
Button(onClick = onMicClick) { Text("マイク") }
|
||||
Button(onClick = onClearClick) { Text("クリア") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.voiceapp.ui.components
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.example.voiceapp.ui.ChatMessage
|
||||
|
||||
@Composable
|
||||
fun ConversationHistory(messages: List<ChatMessage>, modifier: Modifier = Modifier) {
|
||||
LazyColumn(modifier = modifier) {
|
||||
items(messages) { msg ->
|
||||
Text(text = "${msg.sender}: ${msg.text}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.example.voiceapp.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.example.voiceapp.ui.MainViewModel
|
||||
|
||||
@Composable
|
||||
fun MainScreen(viewModel: MainViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val history by viewModel.conversationHistory.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
ControlBar(
|
||||
state = uiState,
|
||||
onMicClick = { viewModel.onMicButtonClicked() },
|
||||
onClearClick = { viewModel.clearHistory() }
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
StatusBanner(state = uiState)
|
||||
ConversationHistory(messages = history, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.voiceapp.ui.components
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun PermissionDialog(onRequestPermission: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("マイク権限") },
|
||||
confirmButton = { Button(onClick = onRequestPermission) { Text("許可") } }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.example.voiceapp.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.voiceapp.ui.VoiceAppState
|
||||
import com.example.voiceapp.ui.theme.ErrorColor
|
||||
import com.example.voiceapp.ui.theme.IdleColor
|
||||
import com.example.voiceapp.ui.theme.ListeningColor
|
||||
import com.example.voiceapp.ui.theme.ProcessingColor
|
||||
import com.example.voiceapp.ui.theme.SpeakingColor
|
||||
|
||||
@Composable
|
||||
fun StatusBanner(state: VoiceAppState) {
|
||||
val color = when (state) {
|
||||
is VoiceAppState.Idle -> IdleColor
|
||||
is VoiceAppState.Listening -> ListeningColor
|
||||
is VoiceAppState.Processing -> ProcessingColor
|
||||
is VoiceAppState.Speaking -> SpeakingColor
|
||||
is VoiceAppState.Error -> ErrorColor
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().background(color).padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = state.javaClass.simpleName, color = Color.White, fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.example.voiceapp.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun WaveformIndicator(rmsDb: Float) {
|
||||
Row {
|
||||
Box(modifier = Modifier.width(4.dp).height((10 + rmsDb * 2).dp).background(Color.White))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.voiceapp.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val IdleColor = Color(0xFF607D8B)
|
||||
val ListeningColor = Color(0xFF2196F3)
|
||||
val ProcessingColor = Color(0xFFFF9800)
|
||||
val SpeakingColor = Color(0xFF4CAF50)
|
||||
val ErrorColor = Color(0xFFF44336)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.voiceapp.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun VoiceControlledDeviceTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = lightColorScheme(), content = content)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.voiceapp.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
val Typography = Typography()
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.voiceapp.voice
|
||||
|
||||
import android.speech.tts.TextToSpeech
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ITextToSpeech {
|
||||
val status: StateFlow<TtsStatus>
|
||||
fun speak(text: String, queueMode: Int = TextToSpeech.QUEUE_FLUSH)
|
||||
fun stop()
|
||||
fun destroy()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.voiceapp.voice
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface IVoiceRecognizer {
|
||||
val state: StateFlow<VoiceRecognitionState>
|
||||
val rmsDb: StateFlow<Float>
|
||||
|
||||
fun startListening()
|
||||
fun stopListening()
|
||||
fun destroy()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.example.voiceapp.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.Locale
|
||||
|
||||
class TextToSpeechManager(private val context: Context) : ITextToSpeech, TextToSpeech.OnInitListener {
|
||||
private var tts: TextToSpeech? = null
|
||||
private var isInitialized = false
|
||||
private val _status = MutableStateFlow<TtsStatus>(TtsStatus.Idle)
|
||||
override val status: StateFlow<TtsStatus> = _status.asStateFlow()
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
|
||||
init { tts = TextToSpeech(context, this) }
|
||||
|
||||
override fun onInit(status: Int) {
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
tts?.setLanguage(Locale.JAPANESE)
|
||||
isInitialized = true
|
||||
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) { _status.value = TtsStatus.Speaking }
|
||||
override fun onDone(utteranceId: String?) { _status.value = TtsStatus.Completed }
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(utteranceId: String?) { _status.value = TtsStatus.Error("TTSエラー") }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun speak(text: String, queueMode: Int) {
|
||||
if (!isInitialized) return
|
||||
val utteranceId = System.currentTimeMillis().toString()
|
||||
tts?.speak(text, queueMode, null, utteranceId)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
tts?.stop()
|
||||
_status.value = TtsStatus.Idle
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.voiceapp.voice
|
||||
|
||||
sealed interface TtsStatus {
|
||||
object Idle : TtsStatus
|
||||
object Speaking : TtsStatus
|
||||
object Completed : TtsStatus
|
||||
data class Error(val message: String) : TtsStatus
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.voiceapp.voice
|
||||
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
|
||||
data class NotificationItem(val title: String, val text: String, val postTime: Long)
|
||||
|
||||
class VoiceNotificationListenerService : NotificationListenerService() {
|
||||
companion object {
|
||||
val recentNotifications = mutableListOf<NotificationItem>()
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
super.onNotificationPosted(sbn)
|
||||
sbn?.notification?.extras?.let { extras ->
|
||||
val title = extras.getString("android.title") ?: ""
|
||||
val text = extras.getCharSequence("android.text")?.toString() ?: ""
|
||||
if (title.isNotEmpty() || text.isNotEmpty()) {
|
||||
recentNotifications.add(0, NotificationItem(title, text, sbn.postTime))
|
||||
if (recentNotifications.size > 10) recentNotifications.removeAt(recentNotifications.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.example.voiceapp.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.Locale
|
||||
|
||||
class VoiceRecognitionManager(private val context: Context) : IVoiceRecognizer {
|
||||
private var speechRecognizer: SpeechRecognizer? = null
|
||||
private val _state = MutableStateFlow<VoiceRecognitionState>(VoiceRecognitionState.Idle)
|
||||
override val state: StateFlow<VoiceRecognitionState> = _state.asStateFlow()
|
||||
private val _rmsDb = MutableStateFlow(0f)
|
||||
override val rmsDb: StateFlow<Float> = _rmsDb.asStateFlow()
|
||||
|
||||
init { initRecognizer() }
|
||||
|
||||
private fun initRecognizer() {
|
||||
if (SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||
speechRecognizer?.destroy()
|
||||
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context).apply { setRecognitionListener(Listener()) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun startListening() {
|
||||
if (speechRecognizer == null) initRecognizer()
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.JAPAN.toString())
|
||||
}
|
||||
_state.value = VoiceRecognitionState.Listening
|
||||
speechRecognizer?.startListening(intent)
|
||||
}
|
||||
|
||||
override fun stopListening() {
|
||||
speechRecognizer?.stopListening()
|
||||
_state.value = VoiceRecognitionState.Idle
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
speechRecognizer?.destroy()
|
||||
speechRecognizer = null
|
||||
}
|
||||
|
||||
private inner class Listener : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) { _state.value = VoiceRecognitionState.Ready }
|
||||
override fun onBeginningOfSpeech() { _state.value = VoiceRecognitionState.Listening }
|
||||
override fun onRmsChanged(rmsdB: Float) { _rmsDb.value = rmsdB }
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
override fun onEndOfSpeech() {}
|
||||
override fun onError(error: Int) {
|
||||
_state.value = VoiceRecognitionState.Error(error, "認識エラー: $error")
|
||||
}
|
||||
override fun onResults(results: Bundle?) {
|
||||
val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
if (!matches.isNullOrEmpty()) {
|
||||
_state.value = VoiceRecognitionState.Success(matches[0])
|
||||
} else {
|
||||
_state.value = VoiceRecognitionState.Error(-1, "発言が聞き取れませんでした")
|
||||
}
|
||||
}
|
||||
override fun onPartialResults(partialResults: Bundle?) {}
|
||||
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.voiceapp.voice
|
||||
|
||||
sealed interface VoiceRecognitionState {
|
||||
object Idle : VoiceRecognitionState
|
||||
object Ready : VoiceRecognitionState
|
||||
object Listening : VoiceRecognitionState
|
||||
data class Success(val recognizedText: String) : VoiceRecognitionState
|
||||
data class Error(val errorCode: Int, val message: String) : VoiceRecognitionState
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
<string name="app_name">音声操作端末</string>
|
||||
<string name="status_idle">待機中 (タップしてお話しください)</string>
|
||||
<string name="status_listening">音声認識中...</string>
|
||||
<string name="status_processing">コマンド解析中...</string>
|
||||
<string name="status_speaking">音声応答中...</string>
|
||||
<string name="status_error">エラーが発生しました</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.VoiceControlledDevice" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@color/purple_700</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.example.voiceapp.domain
|
||||
|
||||
import com.example.voiceapp.domain.executor.GetTimeDateExecutor
|
||||
import com.example.voiceapp.domain.executor.SetAlarmTimerExecutor
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class CommandParserTest {
|
||||
private lateinit var parser: CommandParser
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val definitions = listOf(
|
||||
CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()),
|
||||
CommandDefinition(CommandId.SET_ALARM_TIMER, listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), SetAlarmTimerExecutor())
|
||||
)
|
||||
parser = CommandParser(definitions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_getTime_returnsGetTimeDateCommand() {
|
||||
val result = parser.parse("今何時?")
|
||||
assertEquals(CommandId.GET_TIME_DATE, result.commandId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_setTimer_withKanji_returnsSetAlarmTimerCommand() {
|
||||
val result = parser.parse("三分タイマーかけて")
|
||||
assertEquals(CommandId.SET_ALARM_TIMER, result.commandId)
|
||||
assertEquals("3", result.extractedParameters["value"])
|
||||
assertEquals("分", result.extractedParameters["unit"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_unknownText_returnsUnknownFallback() {
|
||||
val result = parser.parse("こんにちは")
|
||||
assertEquals(CommandId.UNKNOWN_FALLBACK, result.commandId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.voiceapp.domain
|
||||
|
||||
import com.example.voiceapp.domain.executor.GetTimeDateExecutor
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ExecuteCommandUseCaseTest {
|
||||
private lateinit var useCase: ExecuteCommandUseCase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val definitions = listOf(
|
||||
CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor())
|
||||
)
|
||||
val parser = CommandParser(definitions)
|
||||
useCase = ExecuteCommandUseCase(parser)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invoke_getTime_returnsSuccessResult() = runTest {
|
||||
val result = useCase("今何時")
|
||||
assertEquals(CommandId.GET_TIME_DATE, result.commandId)
|
||||
assertTrue(result.isSuccess)
|
||||
assertTrue(result.responseText.contains("現在"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.voiceapp.domain
|
||||
|
||||
import com.example.voiceapp.domain.converter.KanjiToNumberConverter
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class KanjiToNumberConverterTest {
|
||||
|
||||
@Test
|
||||
fun convert_kanjiDigits_convertsToNumbers() {
|
||||
assertEquals("3分", KanjiToNumberConverter.convert("三分"))
|
||||
assertEquals("7時", KanjiToNumberConverter.convert("七時"))
|
||||
assertEquals("15分", KanjiToNumberConverter.convert("十五分"))
|
||||
assertEquals("3分", KanjiToNumberConverter.convert("3 minutes"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.example.voiceapp.ui
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.example.voiceapp.domain.ExecuteCommandUseCase
|
||||
import com.example.voiceapp.domain.model.CommandId
|
||||
import com.example.voiceapp.domain.model.CommandResult
|
||||
import com.example.voiceapp.voice.ITextToSpeech
|
||||
import com.example.voiceapp.voice.IVoiceRecognizer
|
||||
import com.example.voiceapp.voice.TtsStatus
|
||||
import com.example.voiceapp.voice.VoiceRecognitionState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val useCase: ExecuteCommandUseCase = mockk()
|
||||
private val recognizer: IVoiceRecognizer = mockk(relaxed = true)
|
||||
private val tts: ITextToSpeech = mockk(relaxed = true)
|
||||
|
||||
private val recognizerState = MutableStateFlow<VoiceRecognitionState>(VoiceRecognitionState.Idle)
|
||||
private val rmsDbState = MutableStateFlow(0f)
|
||||
private val ttsStatusState = MutableStateFlow<TtsStatus>(TtsStatus.Idle)
|
||||
|
||||
private lateinit var viewModel: MainViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
every { recognizer.state } returns recognizerState
|
||||
every { recognizer.rmsDb } returns rmsDbState
|
||||
every { tts.status } returns ttsStatusState
|
||||
|
||||
viewModel = MainViewModel(useCase, recognizer, tts)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun processUserSpeech_updatesHistoryAndState() = runTest {
|
||||
coEvery { useCase("今何時") } returns CommandResult(CommandId.GET_TIME_DATE, true, "現在は10時00分です。")
|
||||
|
||||
viewModel.processUserSpeech("今何時")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
viewModel.conversationHistory.test {
|
||||
val history = awaitItem()
|
||||
assertEquals(2, history.size)
|
||||
assertEquals("今何時", history[0].text)
|
||||
assertEquals("現在は10時00分です。", history[1].text)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user