たくさんの自由帳

Android で Vosk を使って端末内音声の字幕アプリを作る

投稿日 : | 0 日前

文字数(だいたい) : 9406

どうもこんばんわ。
ジュエリー・ナイツ・アルカディア -The ends of the world- 攻略しました。
前作より読みやすいと思った!!今作は戦闘要素控えめに感じました(前作の戦闘&戦闘だと疲れる...)

前作の駆け込みの部分の話!!があった。そっちのミリアちゃんに助けられたのか。。
輝け、私たちの未来、確かにそうだった。

前作のポイントと言うか要素が出てくるとうれc。
前作、先生が早々に退場しちゃってえ?ってのがここに来てフォーカスがあたったって、おぉ~~って

すくしょ

ミリアちゃん!!!
助けに来てくれるところ良すぎた、、、

すくしょ
キラキラ目

すくしょ

プリちゃん呼びいつの間にか言われなくなって草

すくしょ

すくしょ

はぐれドラゴンの言い方よ
ルクリナさんの戦いのやつ、主人公が上手く切り抜けたのすごいと思った、

すくしょ

すくしょ

!!!!
髪型!!かわいい!

すくしょ

すくしょ

!??!?!?!!!!

すくしょ

ブチギレビジョンブラッドなんかおもろい
前作ヒロインの個別もあり!ます!1

すくしょ
わたし的前作ヒロインの個別 No.1、ルビィちゃん

おすすめ!です、とにかく!かわいい!ので
戦闘&戦闘だったらどうしよう...って思ってたけどそんな事なかった。

本題

Google Pixelには端末から再生してる喋ってる声を文字に起こしてくれる(字幕)機能があります。
スクショ

どうやら他にもGalaxyとかにもあるそうですが、メインで使ってるXperiaにはこの機能が無いみたいです。。そんな。

生配信見てるときにあー腹痛が痛いなートイレにスマホ持っていって見るか・・・ってときに
Pixel持ち出したら字幕機能がある。でも間違えてXperia持ち出したら、、、無い!!

どうにか自作出来ないものか・・・

端末内の音声を文字起こしするアプリを探してるんだから邪魔しないで

はい。審査中なので通過すれば以下のリンクからダウンロード出来るはずです。
https://play.google.com/store/apps/details?id=io.github.takusan23.hiroid

playconsole

すくしょ

文字起こし技術

Androidには昔ながらの文字起こしSpeechRecognizer APIがあります。
マイクで取った喋り超えを文字に起こしてくれる。しかもオフライン対応、途中の文章(確定していない段階の文字起こし)の取得ができたりと便利。

が、が、が、
今回やりたいのはマイクの音声じゃなくて端末内で再生してる音声で文字起こしして欲しい。

SpeechRecognizer

SpeechRecognizerでマイク以外で入力できないかドキュメント見てみました。

ありました。EXTRA_AUDIO_SOURCE。音声PCMファイルのParcelFileDescriptorを渡せる。 https://developer.android.com/reference/android/speech/RecognizerIntent#EXTRA_AUDIO_SOURCE

でも動かない。onError() 5で終了してしまう。
日本語なのがだめなのか、そもそも間違ってるのか、よく分からないけど無理だった。終わり。

// not working !!
val fd = ParcelFileDescriptor.open(context.getExternalFilesDir(null)!!.resolve("pcm").apply { println(path) }, ParcelFileDescriptor.MODE_READ_ONLY)
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
    putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ja-JP")
    putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, "ja-JP")
    putExtra(RecognizerIntent.EXTRA_AUDIO_SOURCE, fd)
    putExtra(RecognizerIntent.EXTRA_AUDIO_SOURCE_ENCODING, AudioFormat.ENCODING_PCM_16BIT)
    putExtra(RecognizerIntent.EXTRA_AUDIO_SOURCE_CHANNEL_COUNT, 2)
    putExtra(RecognizerIntent.EXTRA_AUDIO_SOURCE_SAMPLING_RATE, 44100)
}
val recognizer = SpeechRecognizer.createSpeechRecognizer(context)
recognizer?.setRecognitionListener(object : RecognitionListener {
    override fun onReadyForSpeech(params: Bundle?) {
        println("onReadyForSpeech")
    }
 
    override fun onBeginningOfSpeech() {
        println("onBeginningOfSpeech")
    }
 
    override fun onRmsChanged(rmsdB: Float) {
        println("onRmsChanged")
    }
 
    override fun onBufferReceived(buffer: ByteArray?) {
        println("onBufferReceived")
    }
 
    override fun onEndOfSpeech() {
        println("onEndOfSpeech")
    }
 
    override fun onError(error: Int) {
        println("onError $error")
    }
 
    override fun onResults(results: Bundle?) {
        println("onResults $results")
    }
 
    override fun onPartialResults(partialResults: Bundle?) {
        println("onPartialResults $partialResults")
    }
 
    override fun onEvent(eventType: Int, params: Bundle?) {
        println("onEvent $eventType $params")
    }
})
recognizer.startListening(intent)

Vosk

https://github.com/alphacep/vosk-api

音声認識モデルをダウンロードし読み込ませると、オフラインで文字起こしが出来るらしい。
なんとAndroidでも動いちゃいます!!!

implementation("net.java.dev.jna:jna:5.15.0@aar")
implementation("com.alphacephei:vosk-android:0.3.47@aar")

しかもマイク以外にもPCM バイト配列を渡して文字起こし出来るみたいなので、今回はこれを使うことにします。
端末内の音声を渡すので複雑になってますが、マイクを使うならもっと簡単なはず。

作戦

端末内の音声はMediaProjectionを使うことで取得可能です。
前書いたので詳しくはそっちを見てもらうことに。MediaProjectionから未圧縮の音声(PCM)を取得しVoskに渡す感じになります。
https://takusan.negitoro.dev/posts/android_14_media_projection_partial/

また、Voskのモデルはアプリには同梱しないで、後でファイルマネージャーを使って配置させます。
地味にデカかった、、、のと、自分しか使わないので。

環境

端末内の音声が取得できるのがAndroid 10からなので、、、
新しいxperia買いたいです、、、

Android StudioAndroid Studio Meerkat Feature Drop 2024.3.2
minSdk29
端末Pixel 8 Pro / Xperia 1 V

作る

Vosk を入れる

app/build.gradle.ktsに足します。
上2つはVosk、最後のはService()LifecycleOwnerが使えるやつです。
LifecycleOwnerVoskとは関係ないのですが、文字起こしした文字を表示する字幕Viewを作るときに使いたいので!!

dependencies {
 
    // Vosk
    implementation("net.java.dev.jna:jna:5.15.0@aar")
    implementation("com.alphacephei:vosk-android:0.3.47@aar")
    implementation("androidx.lifecycle:lifecycle-service:2.8.7")
 
    // 以下省略

必要な権限

端末内の音声はマイク権限が必要で、
端末内の音声を取るためのMediaProjectionをサービスで動かすためにフォアグラウンドサービス権限を。

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />

権限を要求

権限よこせってダイアログを出します。
サービスを開始する処理はtodoで!

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            HiroidTheme {
                MainScreen()
            }
        }
    }
}
 
@Composable
private fun MainScreen() {
    val context = LocalContext.current
 
    // 権限
    val permissionRequest = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        onResult = { isGranted ->
            if (isGranted) {
                Toast.makeText(context, "権限が付与されました", Toast.LENGTH_SHORT).show()
            }
        }
    )
 
    // 権限を要求
    LaunchedEffect(key1 = Unit) {
        permissionRequest.launch(android.Manifest.permission.RECORD_AUDIO)
    }
 
    Scaffold { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Button(onClick = {
                // todo このあとすぐ
            }) { Text("開始") }
            Button(onClick = {
                // todo このあとすぐ
            }) { Text("終了") }
        }
    }
}

こうなるはず
権限

サービスを作る

Kotlinファイルを作っても良いんですけど、
AndroidManifestに書き忘れる可能性があるので、スクショのように作る事もできます。名前はVoskCaptionService.ktで。

サービス作る

いや~~Activity / Fragment全盛期はこっから作ってましたね、懐かしい。
レイアウトファイルも作ってくれるし。

話を戻して、サービスを作ったら、AndroidManifestを開き、作った<service>へ属性を一つ足します。
以下のように。android:foregroundServiceType="mediaProjection"の部分ですね。これでサービスでMediaProjectionが使えます。

<service
    android:name=".VoskCaptionService"
    android:enabled="true"
    android:exported="true"
    android:foregroundServiceType="mediaProjection"></service>

サービスですが、LifecycleOwner付きのサービスLifecycleService()を継承するように修正します。
あとはサービスを開始・終了するユーティリティ関数的なものを作っておきました。
companion object { }のやつですね。

class VoskCaptionService : LifecycleService() {
 
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)
        // todo この後すぐ!!!
        return START_NOT_STICKY
    }
 
    companion object {
 
        // Intent に MediaProjection の結果を入れるのでそのキー
        private const val INTENT_KEY_MEDIA_PROJECTION_RESULT_CODE = "result_code"
        private const val INTENT_KEY_MEDIA_PROJECTION_RESULT_DATA = "result_data"
 
        /** フォアグラウンドサービスを開始する */
        fun startService(context: Context, resultCode: Int, data: Intent) {
            val intent = Intent(context, VoskCaptionService::class.java).apply {
                // サービスで MediaProjection を開始するのに必要
                putExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_CODE, resultCode)
                putExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_DATA, data)
            }
            ContextCompat.startForegroundService(context, intent)
        }
 
        /** フォアグラウンドサービスを終了する */
        fun stopService(context: Context) {
            val intent = Intent(context, VoskCaptionService::class.java)
            context.stopService(intent)
        }
    }
}

MediaProjection を開始する処理

MainActivityで、MediaProjection始めますよ~って。
ボタンを押したときに許可を求めるようにします。

Activity Result API、が登場する前はstartActivityForResultが使われてて、これはresultCodedataが引数としてあったのですが、
登場とともに見かけることが無くなりました、、、
Activity Result APIの中でdataresultCodeを元にonResult = { }の中身をいい感じに作ってくれるようになったので、自分でdataをパースしたりする必要はなくなりました。

が、今回のようにMediaProjectionのためにresultCodedataが必要という場合は、
startActivityForResult相当のStartActivityForResult()を使うと取得できます。

あとMediaProjectionには選択したアプリのみを画面録画できる(Android 14)んですが、
音声は引き続き端末全体になるみたいなので、createConfigForDefaultDisplay()を指定して、選択したアプリのみのメニューを無効にします。

// MediaProjection
val mediaProjectionManager = remember { context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
val mediaProjectionRequest = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.StartActivityForResult(),
    onResult = {
        VoskCaptionService.startService(
            context = context,
            resultCode = it.resultCode,
            data = it.data ?: return@rememberLauncherForActivityResult
        )
    }
)
 
Scaffold { innerPadding ->
    Column(modifier = Modifier.padding(innerPadding)) {
        Button(onClick = {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
                mediaProjectionRequest.launch(mediaProjectionManager.createScreenCaptureIntent(MediaProjectionConfig.createConfigForDefaultDisplay()))
            } else {
                mediaProjectionRequest.launch(mediaProjectionManager.createScreenCaptureIntent())
            }
        }) { Text("開始") }
        Button(onClick = {
            VoskCaptionService.stopService(context)
        }) { Text("終了") }
    }
}

Vosk の処理をする関数

冒頭の通り、モデルは自分でダウンロードしてファイルマネージャーを使って配置させようかなって(自分しか使わない)、
モデルが有るパスを引数に、こんな感じかな。

Voskは部分的に確定した文字を取得することも出来るので、sealed interfaceでどっちかを表現できるように。

/** Vosk を使って文字起こしをする */
class VoskAndroid(private val modelPath: String) {
 
    private var model: Model? = null
    private var recognizer: Recognizer? = null
 
    /** モデルを読み込む */
    suspend fun prepare() {
        withContext(Dispatchers.IO) {
            model = Model(modelPath)
            recognizer = Recognizer(model, SAMPLING_RATE.toFloat())
        }
    }
 
    /** 喋り声の音声(PCM)を入力し、文字起こし結果を取得する */
    suspend fun recognizeFromSpeechPcm(pcmByteArray: ByteArray): VoskResult? {
        val recognizer = recognizer ?: return null
 
        // 文字起こしする
        val isFullyText = withContext(Dispatchers.Default) {
            recognizer.acceptWaveForm(pcmByteArray, pcmByteArray.size)
        }
 
        // JSON なのでパースする
        val voskResult = if (isFullyText) {
            val jsonObject = JSONObject(recognizer.result)
            VoskResult.Result(text = jsonObject.getString("text"))
        } else {
            val jsonObject = JSONObject(recognizer.partialResult)
            VoskResult.Partial(partial = jsonObject.getString("partial"))
        }
 
        // 空文字なら return
        return if (voskResult.isBlank) {
            null
        } else {
            voskResult
        }
    }
 
    /** 破棄する */
    fun destroy() {
        model?.close()
        recognizer?.close()
    }
 
    /** [recognizeFromSpeechPcm]の返り値 */
    sealed interface VoskResult {
        val isBlank: Boolean
            get() = when (this) {
                is Partial -> partial.isBlank()
                is Result -> text.isBlank()
            }
 
        /** 確定した文章 */
        @JvmInline
        value class Result(val text: String) : VoskResult
 
        /** 部分的に確定した文章 */
        @JvmInline
        value class Partial(val partial: String) : VoskResult
    }
 
    companion object {
        /** Vosk で受け付けるサンプリングレート */
        const val SAMPLING_RATE = 16000
    }
}

端末内の音声を録音する関数

詳しくは前書いたMediaProjectionの記事を読んでもらうとして、
今回は映像要らない、音声だけなので、関数を一つ作ってそこに集結させます。

関数の返り値はFlow<ByteArray>です。ByteArrayが端末内の音声のPCMで、これをVoskにかけます。

サンプリングレートをVoskと合わせる感じで。
MediaProjectionがキャンセルされたらFlowもキャンセルで!

/** 端末内の音声を MediaProjection を使って録音する */
object InternalAudioTool {
 
    /** 端末内の音声を録音する */
    @SuppressLint("MissingPermission")
    fun recordInternalAudio(
        context: Context,
        resultCode: Int,
        resultData: Intent
    ) = callbackFlow {
        val mediaProjectionManager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
        val bufferSize = AudioRecord.getMinBufferSize(
            VoskAndroid.SAMPLING_RATE,
            AudioFormat.CHANNEL_IN_MONO,
            AudioFormat.ENCODING_PCM_16BIT
        )
 
        // MediaProjection
        val mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, resultData).apply {
            // 画面録画中のコールバック
            registerCallback(object : MediaProjection.Callback() {
                // MediaProjection 終了時
                override fun onStop() {
                    super.onStop()
                    cancel()
                }
            }, null)
        }
        // 内部音声取るのに使う
        val playbackConfig = AudioPlaybackCaptureConfiguration.Builder(mediaProjection).apply {
            addMatchingUsage(AudioAttributes.USAGE_MEDIA)
            addMatchingUsage(AudioAttributes.USAGE_GAME)
            addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
        }.build()
        val audioFormat = AudioFormat.Builder().apply {
            setEncoding(AudioFormat.ENCODING_PCM_16BIT)
            setSampleRate(VoskAndroid.SAMPLING_RATE)
            setChannelMask(AudioFormat.CHANNEL_IN_MONO)
        }.build()
        val audioRecord = AudioRecord.Builder().apply {
            setAudioPlaybackCaptureConfig(playbackConfig)
            setAudioFormat(audioFormat)
            setBufferSizeInBytes(bufferSize)
        }.build()
 
        // 開始
        try {
            audioRecord.startRecording()
            while (true) {
                yield()
                val pcmAudio = ByteArray(bufferSize)
                audioRecord.read(pcmAudio, 0, pcmAudio.size)
                trySend(pcmAudio)
            }
        } finally {
            audioRecord.stop()
            audioRecord.release()
            mediaProjection.stop()
        }
    }
}

サービスから呼び出して完成

VoskAndroidInternalAudioRecorderをサービスから呼び出すようにして完成です。
こんな感じで、IntentからMediaProjectionを作成するためのパラメーターをもらって、InternalAudioRecorderを呼び、順次PCMVoskにかけて、とりあえずは**println()**しています。

LifecycleService()なので、lifecycleScope.launch { }が出来ちゃいます。素敵。

あとフォアグラウンドサービスなので、通知を出して上げる必要があります。
忘れがち

recordInternalAudio()MediaProjection終了時にcallbackFlowcancel()するようにしているので、
try-finallystopSelf()しています。MediaProjectionの終了でサービスが終了するはず。
あとサービス自体の終了はlifecycleScopeがキャンセルされるので、問題ないはず。

class VoskCaptionService : LifecycleService() {
 
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)
 
        // フォアグラウンドサービス通知を出す
        val notificationManager = NotificationManagerCompat.from(this)
        if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) {
            val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW).apply {
                setName("文字起こしサービス実行中")
            }.build()
            notificationManager.createNotificationChannel(channel)
        }
        val notification = NotificationCompat.Builder(this, CHANNEL_ID).apply {
            setContentTitle("文字起こしサービス")
            setContentText("端末内の音声を収集して、文字起こしをしています。")
            setSmallIcon(R.drawable.ic_launcher_foreground)
        }.build()
        ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
 
        if (intent != null) {
            lifecycleScope.launch {
                // モデルを指定して Vosk
                val modelPath = getExternalFilesDir(null)!!.resolve("vosk-model-small-ja-0.22")
                val voskAndroid = VoskAndroid(modelPath.path).apply { prepare() }
 
                try {
                    withContext(Dispatchers.Default) {
                        InternalAudioTool
                            .recordInternalAudio(
                                context = this@VoskCaptionService,
                                resultCode = intent.getIntExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_CODE, -1),
                                resultData = IntentCompat.getParcelableExtra(intent, INTENT_KEY_MEDIA_PROJECTION_RESULT_DATA, Intent::class.java)!!
                            )
                            .conflate()
                            .collect { pcm ->
                                val result = voskAndroid.recognizeFromSpeechPcm(pcm) ?: return@collect
                                println(result)
                            }
                    }
                } finally {
                    // recordInternalAudio が MediaProjection 終了でキャンセル例外を投げる
                    voskAndroid.destroy()
                    stopSelf()
                }
            }
        }
        return START_NOT_STICKY
    }
 
    companion object {
 
        // 通知周り
        private const val NOTIFICATION_ID = 1234
        private const val CHANNEL_ID = "hiroid_running_service"
 
        // Intent に MediaProjection の結果を入れるのでそのキー
        private const val INTENT_KEY_MEDIA_PROJECTION_RESULT_CODE = "result_code"
        private const val INTENT_KEY_MEDIA_PROJECTION_RESULT_DATA = "result_data"
 
        /** フォアグラウンドサービスを開始する */
        fun startService(context: Context, resultCode: Int, data: Intent) {
            val intent = Intent(context, VoskCaptionService::class.java).apply {
                // サービスで MediaProjection を開始するのに必要
                putExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_CODE, resultCode)
                putExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_DATA, data)
            }
            ContextCompat.startForegroundService(context, intent)
        }
 
        /** フォアグラウンドサービスを終了する */
        fun stopService(context: Context) {
            val intent = Intent(context, VoskCaptionService::class.java)
            context.stopService(intent)
        }
    }
}

モデルをダウンロードする

https://alphacephei.com/vosk/models

ここから、日本語のvosk-model-small-ja-0.22をダウンロードしてきます。
出来たら、解凍して、/storage/emulated/0/Android/data/{アプリケーションID}/files/vosk-model-small-ja-0.22フォルダーで配置します。

ファイルマネージャー

使ってみる

起動してみました。logcatに流れているはず。
そこそこいい感じです。!!!

ようつべ

Partial(partial=まー の 清掃 だ から ねぇ なんか カン 並ぶ に 休日 入る か も しん ない です 平日 だっ て です か いい なぁ と て 舐め て た ん です けど ねぇ)
Partial(partial=まー の 清掃 だ から ねぇ なんか カン 並ぶ に 休日 入る か も しん ない です 平日 だっ て です か いい なぁ と て 舐め て た ん です けど ねぇ)
Result(text=まー の 清掃 だ から ねぇ なんか まー 間 並ぶ に 休日 入る か も しん ない です 平日 だっ て です か いい なぁ と て 舐め て た ん です けど ねぇ)
Partial(partial=動い)
Partial(partial=動い)
Partial(partial=後 いい ん)

字幕をつける

logcatに出してもしょうがないので、何らかの方法で上に字幕を出したいです。
多分二通りあって、

  • ピクチャーインピクチャー
    • お手軽
    • 移動処理、サイズ変更もお任せできる
    • 多分同時に表示できるのは一つだけ
  • WindowManagerで画面の上にオーバーレイするViewを出す
    • 設定画面に移動して、権限を付与する必要
    • 移動処理、サイズ変更は自前
    • 複数出せる、、はず

ただ、ピクチャーインピクチャーは一つしか出せないはず。
ピクチャーインピクチャー状態のアプリを文字起こししたい場合の事を考えると、WindowManagerで作るしか無さそう。
一つしか出せないピクチャーインピクチャーを、文字起こしした字幕で使ってしまうのはもったいない。

WindowManager で JetpackCompose を使う

参考にしました、ありがとうございます。
https://zenn.dev/lanlan_peco/scraps/70dec1df75c425

Activity/Fragmentと違い、ServiceWindowManager#addView + ComposeViewJetpack Composeをオーバーレイするにはひと手間必要なはず。
Jetpack Compose、ライフサイクルとかを密接に使ってそうだし。

WindowManager の権限

これが必要です。これは、設定画面に移動して有効にする必要があるタイプの権限で厳しい。
意地でもPinPAPIを使わせようって。

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

また、設定画面に遷移するボタンを置きました、

Button(onClick = {
    context.startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION))
}) { Text("オーバーレイ権限の設定画面") }

ボタンを押したら、利用者は、一覧からアプリを選んでもらって、許可する。

許可

Service で ComposeView をオーバーレイ表示する

SavedStateRegistryOwnerとかを実装すれば、WindowManagerComposeViewを表示できるようです。
先駆者さんありがとう、

class VoskCaptionService : LifecycleService(), SavedStateRegistryOwner {
 
    private val savedStateRegistryController = SavedStateRegistryController.create(this)
    override val savedStateRegistry: SavedStateRegistry
        get() = savedStateRegistryController.savedStateRegistry
 
    private val windowManager by lazy { getSystemService(Context.WINDOW_SERVICE) as WindowManager }
    private val composeView by lazy {
        ComposeView(this).apply {
            setContent {
                Text(text = "ForegroundService + WindowManager + Compose")
            }
        }
    }
 
    private val params = WindowManager.LayoutParams(
        WindowManager.LayoutParams.WRAP_CONTENT,
        WindowManager.LayoutParams.WRAP_CONTENT,
        WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
        WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
        PixelFormat.TRANSLUCENT
    )
 
    override fun onCreate() {
        super.onCreate()
        composeView.setViewTreeLifecycleOwner(this)
        composeView.setViewTreeSavedStateRegistryOwner(this)
        savedStateRegistryController.performRestore(null)
        windowManager.addView(composeView, params)
    }
 
    override fun onDestroy() {
        super.onDestroy()
        windowManager.removeView(composeView)
    }
 
    // 以下省略...
}

これでサービスを起動すると、テキストがでているはず?
ただ出ているわけじゃなく、ちゃんとActivityの上に描画されているはずです。

オーバーレイ

字幕を表示

Voskの文字起こし結果をComposeViewText()で表示します。
見た目も最低限整えます、、、最低限背景色とかは無いと、、、

まずは変換結果を入れておく配列をmutableStateOf()で用意します。
ComposeState<T>の変更を追跡するので。

Partialのときは赤色、Resultで確定しているときはprimaryの色にしました。

private val voskResultCaptionState = mutableStateOf(emptyList<VoskAndroid.VoskResult>())
private val composeView by lazy {
    ComposeView(this).apply {
        setContent {
            HiroidTheme {
                LazyColumn(
                    modifier = Modifier
                        .background(MaterialTheme.colorScheme.primaryContainer)
                        .size(300.dp)
                ) {
                    // 表示する
                    items(voskResultCaptionState.value) { result ->
                        Text(
                            text = when (result) {
                                is VoskAndroid.VoskResult.Partial -> result.partial
                                is VoskAndroid.VoskResult.Result -> result.text
                            },
                            color = when (result) {
                                is VoskAndroid.VoskResult.Partial -> MaterialTheme.colorScheme.error
                                is VoskAndroid.VoskResult.Result -> MaterialTheme.colorScheme.primary
                            }
                        )
                        HorizontalDivider()
                    }
                }
            }
        }
    }
}

あとはVoskの文字起こし結果の部分と繋げます。
filterIsInstance()Resultだけにします。一番最初だけはPartialも受け入れます。これで部分的に確定した文字を一番上に表示し、確定したものは下に積んでいく事ができます。

InternalAudioTool
    .recordInternalAudio(
        context = this@VoskCaptionService,
        resultCode = intent.getIntExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_CODE, -1),
        resultData = IntentCompat.getParcelableExtra(intent, INTENT_KEY_MEDIA_PROJECTION_RESULT_DATA, Intent::class.java)!!
    )
    .conflate()
    .collect { pcm ->
        // 文字起こし
        val result = voskAndroid.recognizeFromSpeechPcm(pcm) ?: return@collect
        // 配列に足す
        // Partial は配列に一個あれば良い
        voskResultCaptionState.value = listOf(result) + voskResultCaptionState.value.filterIsInstance<VoskAndroid.VoskResult.Result>()
    }

こんな感じになってるはず!!!
UI がいまいちですが、一番上は部分的に確定した文字が更新されて、確定したら下に積まれていく感じだと思います。

文字起こしView

動かせるようにする

ComposeViewは目一杯広がっているわけじゃないので、Composeの中身をOffsetでずらすとかは出来ないです。
WindowManagerにレイアウトを更新する関数があるので、ComposeViewと位置を渡すとComposeView自体を動かすことが出来ます。

ComposeView直下のコンポーネントModifierに長押し移動コールバックを追加します。
detectDragGestures { }ですね。これでLayoutParamsの値ずらして、WindowManagerの関数を呼ぶ。

modifier = Modifier
    .background(MaterialTheme.colorScheme.primaryContainer)
    .size(300.dp)
    .pointerInput(key1 = Unit) {
        detectDragGestures { change, dragAmount ->
            change.consume()
            params.x += dragAmount.x.toInt()
            params.y += dragAmount.y.toInt()
            windowManager.updateViewLayout(this@apply, params)
        }
    }

これで動かせるようになったはずです!!

動かせる

完成

見た目とかはまた今度で、、、

完成

ソースコード

https://github.com/takusan23/Hiroid

おわりに

デメリットですが、MediaProjectionを使ってるので実質画面録画してるようなものです、、、

デメリット

おわりに2

まじで関係ないけど

サンダーバード

サンダーバードで思い出した。

Thunderbird派でしたか?Outlook派でしたか?
ちなみに私はWindows XPBecky!を入れて使ってたはずです。
受信トレイを受信(で合ってる?)すると左上にあるBeckyのロゴが動いてたのをずっと見てた記憶。

全然覚えてないけど

ちなみにシェアウェアだってことを高校生くらいのときに知ったんですが、親のライセンスだったのかな(よくわからない)
窓の杜開いたら懐かし~って
(ちなみにそこの頃はまどのしゃって呼んでたと思う、まどのもりって読めるはずない)