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 +++++++++++++++++++-------------------
This commit is contained in:
MythEclipse
2026-06-13 17:08:54 +07:00
parent 9bde518b71
commit 28baeda5ad
11 changed files with 161 additions and 54 deletions
@@ -0,0 +1 @@
ALTER TABLE "voice_recordings" ADD COLUMN "transcription" text;
@@ -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)
@@ -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");
@@ -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<string | null> {
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;
}
}
@@ -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),
@@ -95,6 +95,23 @@ export async function updateVoiceRecordingAsFailed(
}
}
export async function updateVoiceRecordingTranscription(
id: string,
transcription: string,
): Promise<void> {
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<VoiceRecording[]> {