MENU

【Androidアプリ】運転中のメモを「ゼロ工数」化する自作ボイスレコーダー開発記(第4回:最終仕上げと完成)

  • URLをコピーしました!

自作アプリ『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にコーディングをお願いできるという現代に本当、感謝です。音声メモのゼロ工数化はこれで完了になります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

目次