From 28baeda5adf692e006a06f0724457c736f5f9e37 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 13 Jun 2026 17:08:54 +0700 Subject: [PATCH] chore(services): update components based on recent changes Changes: packages/shared/src/config/index.ts | 7 +++ packages/shared/src/moderation-types.ts | 1 + services/backend/src/modules/recordings/recordings.service.ts | 3 +- services/discord-gateway/drizzle/migrations/0011_add_voice_transcription.sql | 1 + services/discord-gateway/src/modules/voice-recording/recorder/segment.ts | 10 ++++ services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts | 15 ++++++ services/discord-gateway/src/modules/voice-recording/voiceTranscriber.ts | 51 ++++++++++++++++++ services/discord-gateway/src/shared/database/schema.ts | 1 + services/discord-gateway/src/shared/database/voiceRecordingRepo.ts | 17 ++++++ services/frontend/src/features/live/components/RecordingsSubPanel.tsx | 108 +++++++++++++++++++------------------- --- packages/shared/src/config/index.ts | 7 ++ packages/shared/src/moderation-types.ts | 1 + .../modules/recordings/recordings.service.ts | 3 +- .../0011_add_voice_transcription.sql | 1 + .../voice-recording/recorder/segment.ts | 10 ++ .../voice-recording/recorder/uploader.ts | 15 +++ .../voice-recording/voiceTranscriber.ts | 51 +++++++++ .../src/shared/database/schema.ts | 1 + .../src/shared/database/voiceRecordingRepo.ts | 17 +++ .../live/components/RecordingsSubPanel.tsx | 108 +++++++++--------- services/frontend/src/shared/api/client.ts | 1 + 11 files changed, 161 insertions(+), 54 deletions(-) create mode 100644 services/discord-gateway/drizzle/migrations/0011_add_voice_transcription.sql create mode 100644 services/discord-gateway/src/modules/voice-recording/voiceTranscriber.ts diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index a6cfe4f..3331a93 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -162,6 +162,13 @@ export const configSchema = z .default(50), PISCINA_MAX_THREADS: z.coerce.number().int().positive().optional(), + // ── Voice Transcription ──────────────────────────────────────────────── + AI_VOICE_TRANSCRIPTION_ENABLED: z + .string() + .optional() + .transform((v) => v === "true") + .default(false), + // ── OpenAI Moderation ─────────────────────────────────────────────── OPENAI_MODERATION_API_KEY: z.string().optional(), OPENAI_MODERATION_BASE_URL: z diff --git a/packages/shared/src/moderation-types.ts b/packages/shared/src/moderation-types.ts index a080dfd..ab33481 100644 --- a/packages/shared/src/moderation-types.ts +++ b/packages/shared/src/moderation-types.ts @@ -167,6 +167,7 @@ export interface VoiceRecordingUploadData { upload_status: string; created_at: number; uploaded_at: number; + transcription?: string | null; } export interface AnalysisQueueStatus { diff --git a/services/backend/src/modules/recordings/recordings.service.ts b/services/backend/src/modules/recordings/recordings.service.ts index 6379be8..08a9706 100644 --- a/services/backend/src/modules/recordings/recordings.service.ts +++ b/services/backend/src/modules/recordings/recordings.service.ts @@ -17,6 +17,7 @@ export interface RecordingRow { download_url: string | null; upload_status: string; upload_error: string | null; + transcription: string | null; created_at: number; uploaded_at: number | null; duration_bytes: number; @@ -59,7 +60,7 @@ export class RecordingsService { SELECT id, user_id, username, avatar_url, guild_id, channel_id, channel_name, filename, size_bytes, download_url, - upload_status, upload_error, created_at, uploaded_at, + upload_status, upload_error, transcription, created_at, uploaded_at, COALESCE(size_bytes, 0) AS duration_bytes FROM voice_recordings ${sql.raw(whereClause)} diff --git a/services/discord-gateway/drizzle/migrations/0011_add_voice_transcription.sql b/services/discord-gateway/drizzle/migrations/0011_add_voice_transcription.sql new file mode 100644 index 0000000..17563ca --- /dev/null +++ b/services/discord-gateway/drizzle/migrations/0011_add_voice_transcription.sql @@ -0,0 +1 @@ +ALTER TABLE "voice_recordings" ADD COLUMN "transcription" text; diff --git a/services/discord-gateway/src/modules/voice-recording/recorder/segment.ts b/services/discord-gateway/src/modules/voice-recording/recorder/segment.ts index b89dc40..f01bc00 100644 --- a/services/discord-gateway/src/modules/voice-recording/recorder/segment.ts +++ b/services/discord-gateway/src/modules/voice-recording/recorder/segment.ts @@ -84,6 +84,16 @@ export async function collectUserMetadata( cacheMetadata(userId, result); return result; +} + +function cacheMetadata(userId: string, metadata: UserMetadata): void { + if (metadataCache.size >= METADATA_CACHE_MAX) { + // Evict oldest entry via Map iteration (Map preserves insertion order) + const firstKey = metadataCache.keys().next().value; + if (firstKey) metadataCache.delete(firstKey); + } + metadataCache.set(userId, metadata); +} // --------------------------------------------------------------------------- // Path helpers (was segment.ts) diff --git a/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts b/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts index 922752a..85649bf 100644 --- a/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts +++ b/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts @@ -6,8 +6,10 @@ import { insertVoiceRecording, updateVoiceRecordingAsFailed, updateVoiceRecordingAsUploaded, + updateVoiceRecordingTranscription, } from "../../../shared/database/voiceRecordingRepo.js"; import { uploadToTele } from "../teleUpload.js"; +import { transcribeRecording } from "../voiceTranscriber.js"; const logger = createChildLogger("recording-uploader"); @@ -94,6 +96,19 @@ export async function uploadRecordingSegment(input: { ); }); } + + // 5. Fire-and-forget voice transcription + if (config.AI_VOICE_TRANSCRIPTION_ENABLED) { + transcribeRecording(oggPath).then((transcription) => { + if (transcription) { + updateVoiceRecordingTranscription(id, transcription).catch( + (err: unknown) => { + logger.warn({ id, err }, "Failed to persist transcription"); + }, + ); + } + }); + } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); logger.error({ id, error: errorMsg }, "Failed to upload voice recording"); diff --git a/services/discord-gateway/src/modules/voice-recording/voiceTranscriber.ts b/services/discord-gateway/src/modules/voice-recording/voiceTranscriber.ts new file mode 100644 index 0000000..558c6b9 --- /dev/null +++ b/services/discord-gateway/src/modules/voice-recording/voiceTranscriber.ts @@ -0,0 +1,51 @@ +// ─── Voice Transcription — AI-powered speech-to-text for voice recordings ──── + +import { createReadStream } from "node:fs"; +import { createChildLogger } from "@bete/shared/logger"; +import OpenAI from "openai"; +import { config } from "../../shared/config/config.js"; + +const logger = createChildLogger("voice-transcriber"); + +/** + * Transcribe a voice recording OGG file using OpenAI Whisper API. + * Returns the transcribed text or null on failure. + */ +export async function transcribeRecording( + oggPath: string, +): Promise { + if (!config.AI_VOICE_TRANSCRIPTION_ENABLED) return null; + if (!config.AI_LLM_API_KEY) { + logger.warn("AI_LLM_API_KEY not set, skipping transcription"); + return null; + } + + try { + const openai = new OpenAI({ + apiKey: config.AI_LLM_API_KEY, + baseURL: config.AI_LLM_BASE_URL, + maxRetries: 2, + timeout: 120_000, + }); + + const response = await openai.audio.transcriptions.create({ + file: createReadStream(oggPath), + model: "whisper-1", + language: "en", + response_format: "text", + }); + + const text = typeof response === "string" ? response.trim() : null; + if (!text) { + logger.warn({ oggPath }, "Empty transcription result"); + return null; + } + + logger.info({ oggPath, length: text.length }, "Transcription completed"); + return text; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error({ oggPath, error: msg }, "Transcription failed"); + return null; + } +} diff --git a/services/discord-gateway/src/shared/database/schema.ts b/services/discord-gateway/src/shared/database/schema.ts index 48e4a5a..94be200 100644 --- a/services/discord-gateway/src/shared/database/schema.ts +++ b/services/discord-gateway/src/shared/database/schema.ts @@ -111,6 +111,7 @@ export const pgVoiceRecordingsTable = pgTable( upload_error: pgText("upload_error"), created_at: pgBigint("created_at", { mode: "number" }).notNull(), uploaded_at: pgBigint("uploaded_at", { mode: "number" }), + transcription: pgText("transcription"), }, (table) => ({ userIdIdx: pgIndex("idx_voice_recordings_user_id").on(table.user_id), diff --git a/services/discord-gateway/src/shared/database/voiceRecordingRepo.ts b/services/discord-gateway/src/shared/database/voiceRecordingRepo.ts index 14df6d6..2c8d6d9 100644 --- a/services/discord-gateway/src/shared/database/voiceRecordingRepo.ts +++ b/services/discord-gateway/src/shared/database/voiceRecordingRepo.ts @@ -95,6 +95,23 @@ export async function updateVoiceRecordingAsFailed( } } +export async function updateVoiceRecordingTranscription( + id: string, + transcription: string, +): Promise { + try { + await db() + .update(voiceRecordingsTable) + .set({ transcription }) + .where(eq(voiceRecordingsTable.id, id)); + } catch (error) { + logger.error( + { id, error: error instanceof Error ? error.message : String(error) }, + "Failed to update voice recording transcription", + ); + } +} + export async function listVoiceRecordings( limit = 100, ): Promise { diff --git a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx index b26b4ec..bd320a6 100644 --- a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx +++ b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx @@ -1,7 +1,7 @@ // ─── Recordings Sub-Panel ── import { Download, Mic, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { VoiceRecording } from "../../../shared/api/client"; import { deleteRecording, listRecordings } from "../../../shared/api/client"; import { formatBytes, formatDate } from "../../../shared/lib/utils"; @@ -124,53 +124,55 @@ export function RecordingsSubPanel() { return (
{recordings.map((rec) => ( -
-
- -
-
-
{rec.filename}
-
- {rec.username} - · - {rec.channel_name ?? rec.channel_id ?? "unknown"} - · - {formatDate(rec.created_at)} - · - {formatBytes(rec.size_bytes)} +
+
+
+
- {rec.upload_error && ( -
- {rec.upload_error} +
+
{rec.filename}
+
+ {rec.username} + · + {rec.channel_name ?? rec.channel_id ?? "unknown"} + · + {formatDate(rec.created_at)} + · + {formatBytes(rec.size_bytes)}
- )} -
-
- - - {rec.upload_status} - - {rec.download_url && ( - <> + {rec.upload_error && ( +
+ {rec.upload_error} +
+ )} + {rec.transcription && ( +
+ {rec.transcription} +
+ )} +
+
+ + + {rec.upload_status} + + {rec.download_url && ( - - )} + )} +
+ {rec.download_url && ( +
+ +
+ )}
- {rec.download_url && ( -
- -
- )} ))} {hasMore && (
diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index dae5162..d7deaf9 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -258,6 +258,7 @@ export interface VoiceRecording { download_url: string | null; upload_status: "pending" | "uploaded" | "failed"; upload_error: string | null; + transcription?: string | null; created_at: number; uploaded_at: number | null; }