自作アプリ『ZeroStepMemo』の開発記の最終回です。前回までのオーディオ制御とUI改善で、アプリの挙動は理想に近づきました。今回は実運用に向けた課題の解決とアイコンの設定を行い、完成したソースコードを公開します。
マナーモードで完了音が消える問題
運転中の利用では、メモが確実に保存されたことを耳で確認できる必要があります。保存完了時にチャイムを鳴らす処理を追加しました。
しかし、スマホがマナーモードやサイレント設定だと、標準のシステム音はOSに消されて無音になります。画面を見て確認してはゼロ工数の意味がありません。
STREAM_ALARMによるマナーモード時への対応
マナーモードでも鳴るアラーム(目覚まし時計)の機能を利用しました。完了の電子音を、メディア音ではなくSTREAM_ALARMで鳴らすように変更しています。
// マナーモードに対応するアラーム音の処理
private fun playChime() {
try {
// STREAM_ALARM を指定して音を鳴らす
val tg = ToneGenerator(AudioManager.STREAM_ALARM, 100)
tg.startTone(ToneGenerator.TONE_PROP_ACK, 150)
Handler(Looper.getMainLooper()).postDelayed({ tg.release() }, 1000)
} catch (e: Exception) { e.printStackTrace() }
}
この方法なら、チャイムの音量はスマホ本体のアラーム音量設定で調整できます。 車内でうるさいと感じたら、スマホの設定を下げるだけで済みます。プログラムの修正は不要です。
オリジナルアイコンの設定
標準のドロイド君から変更します。Android Studioの「Image Asset」機能を使いました。
UIと同じく背景は完全な黒(Deep Black)、アクセントにオレンジを配置したデザインです。これをビルドし、専用のAPKファイルが完成しました。
完成したソースコード
今回作成した『ZeroStepMemo』のフルソースコードです。Jetpack Composeを使用し、1つのファイルにUIとロジックをまとめています。
package com.example.zerostepmemo
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.ToneGenerator
import android.os.*
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
class MainActivity : ComponentActivity() {
private val ColorOrange = Color(0xFFD35400)
private val ColorDeepBlack = Color(0xFF000000)
private val ColorDimText = Color(0xFF888888)
private val ColorRecordingRed = Color(0xFFC0392B)
private val PREFS_NAME = "ZeroStepPrefs"
private val KEY_REC_SECONDS = "wait_seconds"
private val KEY_START_SECONDS = "start_wait_seconds"
private val KEY_FOLDER = "folder_name"
private var recognizedText = mutableStateOf("")
private var isRecording = mutableStateOf(false)
private var isSettingMode = mutableStateOf(false)
private var isSpeaking = mutableStateOf(false)
private var countdown = mutableStateOf(8)
private var currentSilenceSeconds = mutableStateOf(8)
private var recWaitSeconds = mutableStateOf("8")
private var startWaitSeconds = mutableStateOf("8")
private var folderName = mutableStateOf("zerostepmemo")
private var speechRecognizer: SpeechRecognizer? = null
private var speechRecognizerIntent: Intent? = null
private lateinit var audioManager: AudioManager
private var audioFocusRequest: AudioFocusRequest? = null
private var countdownHandler: Handler? = null
private var silenceHandler = Handler(Looper.getMainLooper())
private var silenceRunnable: Runnable? = null
private var accumulatedText = ""
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { if (it) startInitialCountdown() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
loadSettings()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
}
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
setupSpeechRecognizer()
setContent {
val focusManager = LocalFocusManager.current
MaterialTheme {
Box(
modifier = Modifier.fillMaxSize().background(ColorDeepBlack).padding(20.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(40.dp))
StatusDisplay()
Spacer(modifier = Modifier.height(20.dp))
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Text(
text = if (recognizedText.value.isEmpty()) "LISTEN..." else recognizedText.value,
color = ColorDimText,
fontSize = 22.sp,
fontWeight = FontWeight.Light,
lineHeight = 32.sp
)
}
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF111111)),
border = BorderStroke(1.dp, Color.DarkGray),
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
SettingRow("待機:", startWaitSeconds) { enterSettingMode() }
SettingRow("無音:", recWaitSeconds) { enterSettingMode() }
Button(
onClick = { focusManager.clearFocus(); saveSettings() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = Color.DarkGray)
) { Text("設定保存", fontSize = 12.sp) }
}
}
}
FloatingActionButton(
onClick = { if(isRecording.value) finishAndSaveExplicitly() },
containerColor = ColorOrange,
contentColor = Color.White,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 80.dp, end = 20.dp)
.size(70.dp),
shape = RoundedCornerShape(15.dp)
) {
Text("保存", fontWeight = FontWeight.Bold)
}
}
}
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
startInitialCountdown()
} else {
requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
@Composable
fun StatusDisplay() {
if (isSettingMode.value) {
Text("⚙ SETTING MODE", color = ColorOrange, fontWeight = FontWeight.Bold)
} else if (!isRecording.value) {
Text("READY: 録音開始まで ${countdown.value} 秒", color = Color.Gray, fontSize = 18.sp)
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(10.dp).background(ColorRecordingRed, RoundedCornerShape(5.dp)))
Spacer(modifier = Modifier.width(8.dp))
if (isSpeaking.value) {
Text("RECORDING... (音声入力中)", color = ColorRecordingRed, fontWeight = FontWeight.Bold, fontSize = 18.sp)
} else {
Text("RECORDING... (終了まで ${currentSilenceSeconds.value} 秒)", color = ColorRecordingRed, fontWeight = FontWeight.Bold, fontSize = 18.sp)
}
}
}
}
@Composable
fun SettingRow(label: String, state: MutableState<String>, onFocus: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 2.dp)) {
Text(label, color = Color.Gray, fontSize = 12.sp, modifier = Modifier.width(50.dp))
TextField(
value = state.value,
onValueChange = { state.value = it },
modifier = Modifier.weight(1f).onFocusChanged { if(it.isFocused) onFocus() },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp, color = Color.White),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = TextFieldDefaults.colors(unfocusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent)
)
}
}
private fun loadSettings() {
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
recWaitSeconds.value = prefs.getString(KEY_REC_SECONDS, "8") ?: "8"
startWaitSeconds.value = prefs.getString(KEY_START_SECONDS, "8") ?: "8"
folderName.value = prefs.getString(KEY_FOLDER, "zerostepmemo") ?: "zerostepmemo"
countdown.value = startWaitSeconds.value.toIntOrNull() ?: 8
currentSilenceSeconds.value = recWaitSeconds.value.toIntOrNull() ?: 8
}
private fun enterSettingMode() {
if (!isSettingMode.value) {
isSettingMode.value = true
isRecording.value = false
countdownHandler?.removeCallbacksAndMessages(null)
stopSilenceTimer()
speechRecognizer?.cancel()
abandonFocus()
vibrateDevice(1)
}
}
private fun startInitialCountdown() {
countdownHandler?.removeCallbacksAndMessages(null)
countdownHandler = Handler(Looper.getMainLooper())
var remaining = startWaitSeconds.value.toIntOrNull() ?: 8
countdown.value = remaining
countdownHandler?.post(object : Runnable {
override fun run() {
if (isSettingMode.value) return
if (remaining > 0) {
countdown.value = remaining
remaining--
countdownHandler?.postDelayed(this, 1000)
} else {
countdown.value = 0
isRecording.value = true
vibrateDevice(1)
resetSilenceTimer()
startListening()
}
}
})
}
private fun startListening() {
if (isSettingMode.value) return
Handler(Looper.getMainLooper()).post {
requestAudioFocus()
try {
speechRecognizer?.startListening(speechRecognizerIntent)
} catch (e: Exception) {
setupSpeechRecognizer()
speechRecognizer?.startListening(speechRecognizerIntent)
}
}
}
private fun setupSpeechRecognizer() {
speechRecognizer?.destroy()
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(this)
speechRecognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
}
speechRecognizer?.setRecognitionListener(object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {}
override fun onBeginningOfSpeech() {
if (isSettingMode.value) return
isSpeaking.value = true
stopSilenceTimer()
}
override fun onResults(results: Bundle?) {
if (isSettingMode.value) return
isSpeaking.value = false
val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
val text = matches?.get(0) ?: ""
if (text.isNotBlank()) {
accumulatedText += (if (accumulatedText.isEmpty()) "" else " ") + text
recognizedText.value = accumulatedText
}
resetSilenceTimer()
startListening()
}
override fun onPartialResults(partialResults: Bundle?) {
if (isSettingMode.value) return
isSpeaking.value = true
val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
if (!matches.isNullOrEmpty()) {
recognizedText.value = accumulatedText + " " + matches[0]
}
}
override fun onError(error: Int) {
if (!isSettingMode.value) {
isSpeaking.value = false
startListening()
}
}
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {}
override fun onEvent(eventType: Int, params: Bundle?) {}
})
}
private fun resetSilenceTimer() {
stopSilenceTimer()
var remaining = recWaitSeconds.value.toIntOrNull() ?: 8
currentSilenceSeconds.value = remaining
silenceRunnable = object : Runnable {
override fun run() {
if (isSettingMode.value) return
if (remaining > 0) {
currentSilenceSeconds.value = remaining
remaining--
silenceHandler.postDelayed(this, 1000)
} else {
currentSilenceSeconds.value = 0
if (accumulatedText.isNotBlank()) saveTextToFile(accumulatedText)
finish()
}
}
}
silenceHandler.postDelayed(silenceRunnable!!, 1000)
}
private fun stopSilenceTimer() {
silenceRunnable?.let { silenceHandler.removeCallbacks(it) }
}
private fun finishAndSaveExplicitly() {
if (accumulatedText.isNotBlank()) saveTextToFile(accumulatedText)
finish()
}
private fun saveTextToFile(text: String) {
val sdfDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val sdfTime = SimpleDateFormat("HH:mm", Locale.getDefault())
val now = Date()
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val folder = File(downloadsDir, folderName.value)
if (!folder.exists()) folder.mkdirs()
try {
File(folder, "${sdfDate.format(now)}.txt").appendText("- [${sdfTime.format(now)}] $text\n")
playChime()
vibrateDevice(2)
} catch (e: Exception) { e.printStackTrace() }
}
private fun playChime() {
try {
val tg = ToneGenerator(AudioManager.STREAM_ALARM, 100)
tg.startTone(ToneGenerator.TONE_PROP_ACK, 150)
Handler(Looper.getMainLooper()).postDelayed({ tg.release() }, 1000)
} catch (e: Exception) { e.printStackTrace() }
}
private fun saveSettings() {
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().apply {
putString(KEY_REC_SECONDS, recWaitSeconds.value)
putString(KEY_START_SECONDS, startWaitSeconds.value)
putString(KEY_FOLDER, folderName.value)
apply()
}
vibrateDevice(1)
isSettingMode.value = false
accumulatedText = ""; recognizedText.value = ""
setupSpeechRecognizer()
startInitialCountdown()
}
private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build())
.setWillPauseWhenDucked(false).build()
audioManager.requestAudioFocus(audioFocusRequest!!)
}
}
private fun abandonFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && audioFocusRequest != null) {
audioManager.abandonAudioFocusRequest(audioFocusRequest!!)
audioFocusRequest = null
}
}
private fun vibrateDevice(count: Int) {
val vibrator = getSystemService(VIBRATOR_SERVICE) as Vibrator
if (count == 1) {
vibrator.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
vibrator.vibrate(VibrationEffect.createWaveform(longArrayOf(0, 100, 100, 100), -1))
}
}
override fun onDestroy() {
super.onDestroy()
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
stopSilenceTimer()
abandonFocus()
speechRecognizer?.destroy()
}
}既存アプリへの不満から始まり、必要な機能を組み合わせて『ZeroStepMemo』が完成しました。運転中の環境において、自分が求めているアプリの挙動を市販のレコーダーより確実に実行してくれています。不便を自らで改善するプロセスは有意義です。geminiにコーディングをお願いできるという現代に本当、感謝です。音声メモのゼロ工数化はこれで完了になります。
