commit 56e434cb15307c6bf53a2acd7eb62e725c0902fb
parent dc5b91a42ad3a741d59ec010e4eb442a8d8e7800
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sat,  5 Jul 2025 00:49:59 +0200

feat(core): notification transcript

Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>

Diffstat:
Mcommon/src/main/assets/lang/en_US.json | 4++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterTranscript.kt | 37+++++++++++++++++++++++++++++++++----
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt | 26++++++++++++++++++++++++++
4 files changed, 64 insertions(+), 4 deletions(-)

diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -1056,6 +1056,10 @@ "preferred_transcription_lang": { "name": "Preferred Transcription Language", "description": "The preferred language for the voice note transcript (e.g. EN, ES, FR)" + }, + "notification_transcript": { + "name": "Notification Transcript", + "description": "Transcribes voice notes in notifications\nThis feature requires the Chat Preview feature to be enabled in Better Notifications" } } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt @@ -20,6 +20,7 @@ class Experimental : ConfigContainer() { class BetterTranscriptConfig: ConfigContainer(hasGlobalState = true) { val forceTranscription = boolean("force_transcription") { requireRestart() } val preferredTranscriptionLang = string("preferred_transcription_lang") { requireRestart() } + val notificationTranscript = boolean("notification_transcript") { requireRestart() } } class ComposerHooksConfig: ConfigContainer(hasGlobalState = true) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterTranscript.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterTranscript.kt @@ -4,20 +4,47 @@ import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.dataBuilder import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull import me.rhunk.snapenhance.core.util.ktx.setObjectField +import java.nio.ByteBuffer class BetterTranscript: Feature("Better Transcript") { + private val voiceML: Any by lazy { + findClass("com.snapchat.client.voiceml.IVoiceMLSDK").getMethod("create").invoke(null) ?: error("Failed to create IVoiceMLSDK instance") + } + + private fun createAsrConfig(): Any? { + findClass("com.snapchat.client.voiceml.IConfigFactory").methods.first { it.name == "simpleAsrConfig" }.let { method -> + return method.invoke(null, method.parameterTypes[0].dataBuilder { + set("mSampleRate", 44100) + set("mLanguageModel", "en") + set("mUseCase", "VOICENOTESTRANSCRIPTION") + set("mAppVersion", "voice note transcript") + set("mUiLanguage", "en") + set("mAuthType", "SNAPTOKEN") + set("mEncoding", "AAC") + }) + } + } + + fun transcribe(audio: ByteBuffer): String? { + val transcribeMethod = voiceML.javaClass.methods.first { it.name == "asrTranscribe" } + val snapToken = context.database.getAccessTokens(context.database.myUserId)?.get("api-gateway") ?: error("Failed to get api-gateway token") + + return transcribeMethod.invoke(voiceML, snapToken, createAsrConfig(), audio) + ?.let { asrResult -> + asrResult.getObjectFieldOrNull("mTranscription")?.toString() + } + } + override fun init() { if (context.config.experimental.betterTranscript.globalState != true) return onNextActivityCreate { val config = context.config.experimental.betterTranscript - val preferredTranscriptionLang = config.preferredTranscriptionLang.getNullable()?.takeIf { - it.isNotBlank() - } if (config.forceTranscription.get()) { context.event.subscribe(BuildMessageEvent::class, priority = 104) { event -> @@ -33,7 +60,9 @@ class BetterTranscript: Feature("Better Transcript") { } findClass("com.snapchat.client.voiceml.IVoiceMLSDK\$CppProxy").hook("asrTranscribe", HookStage.BEFORE) { param -> - preferredTranscriptionLang?.lowercase()?.let { + config.preferredTranscriptionLang.getNullable()?.takeIf { + it.isNotBlank() + }?.trim()?.lowercase()?.let { val asrConfig = param.arg<Any>(1) asrConfig.getObjectFieldOrNull("mBaseConfig")?.apply { setObjectField("mLanguageModel", it) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt @@ -24,7 +24,9 @@ import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEve import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.impl.FriendMutationObserver import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder +import me.rhunk.snapenhance.core.features.impl.experiments.BetterTranscript import me.rhunk.snapenhance.core.features.impl.spying.StealthMode import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.findRestrictedConstructor @@ -34,6 +36,7 @@ import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.util.media.PreviewUtils import me.rhunk.snapenhance.core.wrapper.impl.Message import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import java.nio.ByteBuffer import kotlin.coroutines.suspendCoroutine class Notifications : Feature("Notifications") { @@ -387,6 +390,29 @@ class Notifications : Feature("Notifications") { }" } + if (contentType == ContentType.NOTE && context.config.experimental.betterTranscript.takeIf { it.globalState == true }?.notificationTranscript?.get() == true) { + MessageDecoder.decode(message.messageContent!!).firstOrNull { it.type == AttachmentType.NOTE }?.also { media -> + runCatching { + media.openStream { mediaStream, length -> + if (mediaStream == null || length > 25 * 1024 * 1024) { + context.log.error("Failed to open media stream or media is too large") + return@openStream + } + + val text = context.feature(BetterTranscript::class).transcribe( + ByteBuffer.allocateDirect(length.toInt()).apply { + put(mediaStream.readBytes()) + rewind() + }) ?: return@openStream + serializedMessage = "\uD83C\uDFA4 $text" + isChatMessage = true + } + }.onFailure { + context.log.error("Failed to transcribe message", it) + } + } + } + if (isChatMessage || config.stackedMediaMessages.get()) { setNotificationText(serializedMessage) } else {