feat(#1247): hold-to-record voice notes with server-side transcription (#1250)

Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
Nikhil NairandM57 authored and GitHub committed 2026-09-25 17:43:52 +03:00
1 parent 0e228dec18
commit ae9003decd
19 files changed
+1619 -89

No files matched your search

@@ -0,0 +1,30 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
/**
* Request body for `POST /api/audio/transcribe` — the dashboard's server-side
* transcription relay, the same endpoint the desktop client's voice path uses.
*
* The recording rides inline as a base64 `data:` URL (the desktop wire shape).
* [OkHttpProvider.json] carries [kotlinx.serialization.json.JsonNamingStrategy.SnakeCase],
* so [dataUrl]/[mimeType] serialize as `data_url`/`mime_type`.
*/
@Serializable
data class AudioTranscriptionRequest(
val dataUrl: String,
val mimeType: String,
)
/**
* Response of `POST /api/audio/transcribe`.
*
* [transcript] is empty (not an error) when the server detects no speech in
* the recording.
*/
@Serializable
data class AudioTranscriptionResponse(
val ok: Boolean = false,
val transcript: String? = null,
val provider: String? = null,
)
@@ -4,9 +4,11 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
/**
* Provides a Retrofit-backed [HermesApiService].
@@ -22,6 +24,9 @@ object ApiClient {
@Volatile
private var service: HermesApiService? = null
@Volatile
private var okHttpClient: OkHttpClient? = null
/** The current [HermesApiService] instance. Lazily created on first access. */
val hermesApi: HermesApiService
get() {
@@ -39,6 +44,7 @@ object ApiClient {
synchronized(this) {
retrofit = null
service = null
okHttpClient = null
}
}
@@ -81,6 +87,64 @@ object ApiClient {
return tempRetrofit.create(HermesApiService::class.java)
}
/**
* Dedicated [HermesApiService] for the blocking voice-note transcription
* POST (`/api/audio/transcribe`).
*
* STT blocks until the profile's provider finishes, so it cannot run on
* the shared 30s client, and a re-submitted POST would repeat expensive
* provider work after the request may already have reached the server
* (the repository also passes `retries = 0`). The returned service reuses
* the shared connection pool and the auth/profile interceptor stack, and
* applies [readTimeoutMs] to reads and writes with transport retries
* disabled (review, PR #1250).
*/
fun transcriptionService(readTimeoutMs: Long): HermesApiService {
val client =
OkHttpProvider
.base
.newBuilder()
.addInterceptor(restAuthInterceptor())
.addInterceptor(ProfileScopeInterceptor)
.authenticator(TokenRefreshAuthenticator)
.readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
.writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(false)
.build()
return Retrofit
.Builder()
.baseUrl(AuthManager.endpointForBuild().baseUrl)
.client(client)
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build()
.create(HermesApiService::class.java)
}
/**
* REST auth stamping shared by every client: token mode sends the bearer
* header; gated (basic-auth cookie) mode leaves the request alone because
* the shared CookieManager jar carries the session cookie and a stray
* Authorization header would 401 (issue #470).
*/
private fun restAuthInterceptor(): Interceptor =
Interceptor { chain ->
val request = chain.request()
if (AuthManager.isGatedMode()) {
return@Interceptor chain.proceed(request)
}
val token = AuthManager.getToken()
if (!token.isNullOrBlank()) {
chain.proceed(
request
.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build(),
)
} else {
chain.proceed(request)
}
}
// ── Internal ─────────────────────────────────────────────────────────
private fun buildService(): HermesApiService =
@@ -111,25 +175,7 @@ object ApiClient {
// must NOT stamp one here — otherwise every REST tab (skills, cron,
// config, ...) fails with "token expired" while the WS chat, which
// authenticates via ?ticket=, keeps working.
val authInterceptor =
Interceptor { chain ->
val request = chain.request()
if (AuthManager.isGatedMode()) {
// Cookie in the shared jar is the only valid REST credential.
return@Interceptor chain.proceed(request)
}
val token = AuthManager.getToken()
if (!token.isNullOrBlank()) {
chain.proceed(
request
.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build(),
)
} else {
chain.proceed(request)
}
}
val authInterceptor = restAuthInterceptor()
val okHttp =
OkHttpProvider
@@ -7,6 +7,8 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.AnalyticsResponse
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
import com.m57.hermescontrol.data.model.BackupTriggerRequest
import com.m57.hermescontrol.data.model.BulkDeleteRequest
@@ -1030,4 +1032,14 @@ interface HermesApiService : KanbanApiService {
suspend fun deleteManagedFile(
@Body body: ManagedFileDelete,
): Response<ManagedFileActionResponse>
// ── Audio: server-side transcription ────────────────────────────────
// The dashboard's voice relay (hermes_cli/web_routers/audio.py) — the
// same endpoint the desktop client's voice notes use. The clip rides
// inline as a base64 data URL; the server resolves STT through the
// active profile's configured provider.
@POST("api/audio/transcribe")
suspend fun transcribeAudio(
@Body body: AudioTranscriptionRequest,
): Response<AudioTranscriptionResponse>
}
@@ -26,6 +26,13 @@ object ProfileScopeInterceptor : Interceptor {
private val PROFILE_SCOPED_PREFIXES =
listOf(
"/api/analytics",
// Voice-note transcription resolves STT through the profile's
// configured provider, so the request must carry the active
// profile (the desktop sends this route `...profileScoped()`;
// hermes_cli/web_routers/audio.py is config-scoped). Unscoped, a
// multi-profile host transcribes under the launch profile
// (review, PR #1250).
"/api/audio",
"/api/config",
"/api/credentials/pool",
"/api/cron",
@@ -0,0 +1,98 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import java.io.File
import java.io.IOException
import java.util.Base64
/**
* MIME type of the recorder's output: MPEG-4 container with AAC audio — see
* `VoiceNoteRecorder` in the chat UI layer.
*/
const val VOICE_NOTE_MIME_TYPE = "audio/mp4"
/**
* Transcribes a recorded voice note through the dashboard's server-side
* transcription endpoint (`POST /api/audio/transcribe`).
*
* The wire shape is the desktop client's: base64 `data:` URL plus MIME type.
* The server resolves STT through the active profile's configured provider
* (the request is profile-scoped by ProfileScopeInterceptor, matching the
* desktop's `...profileScoped()` on this route), so the transcript matches
* the rest of Hermes instead of the phone's on-device recognizer.
*
* Two contracts differ from a plain REST call (review, PR #1250):
* - [serviceProvider] resolves the Retrofit service at CALL time, so a
* connection/profile switch (ApiClient.rebuild) applies to the next
* transcription instead of pinning the construction-time service.
* - The STT POST runs on a dedicated long-timeout client with `retries = 0`:
* re-submitting after a timeout or 5xx would repeat expensive provider
* work although the request may already have reached the server.
*
* Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern).
*/
open class VoiceNoteRepository(
private val serviceProvider: (timeoutMs: Long) -> HermesApiService = { timeoutMs ->
ApiClient.transcriptionService(timeoutMs)
},
) {
open suspend fun transcribe(file: File): NetworkResult<String> {
val encoded =
try {
file.readBytes()
} catch (e: IOException) {
// A local read failure must return a Failure instead of
// escaping as an exception — the caller resets its
// transcription state from this result (review, PR #1250).
return NetworkResult.Failure(
NetworkError.Unknown("Failed to read the voice note: ${e.message}", e),
)
}
val dataUrl = "data:$VOICE_NOTE_MIME_TYPE;base64," + Base64.getEncoder().encodeToString(encoded)
val result =
safeApiCall(retries = 0) {
serviceProvider(transcriptionTimeoutMs(dataUrl)).transcribeAudio(
AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE),
)
}
return when (result) {
is NetworkResult.Success -> {
NetworkResult.Success(
result.data
?.transcript
.orEmpty()
.trim(),
)
}
is NetworkResult.Failure -> {
result
}
}
}
companion object {
/** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */
const val MIN_REQUEST_TIMEOUT_MS = 180_000L
/** Cap so a hung transcription cannot pin the call forever — desktop parity. */
const val MAX_REQUEST_TIMEOUT_MS = 600_000L
private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L
/**
* Timeout for the blocking STT endpoint, mirroring the desktop's
* `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char
* keeps short clips at the floor while multi-minute recordings scale
* toward the cap. Integer ceil() keeps the arithmetic exact.
*/
fun transcriptionTimeoutMs(dataUrl: String): Long =
((dataUrl.length + BASE64_CHARS_PER_TIMEOUT_MS - 1) / BASE64_CHARS_PER_TIMEOUT_MS)
.coerceIn(MIN_REQUEST_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS)
}
}