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
+7
View File
@@ -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
+1
View File
@@ -167,6 +167,7 @@ export interface VoiceRecordingUploadData {
upload_status: string;
created_at: number;
uploaded_at: number;
transcription?: string | null;
}
export interface AnalysisQueueStatus {
@@ -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)}
@@ -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[]> {
@@ -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 (
<div className="space-y-3">
{recordings.map((rec) => (
<div
key={rec.id}
className="flex items-center gap-4 rounded-xl border border-sky-200 bg-white p-4"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Mic className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{rec.filename}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<span>{rec.username}</span>
<span>·</span>
<span>{rec.channel_name ?? rec.channel_id ?? "unknown"}</span>
<span>·</span>
<span>{formatDate(rec.created_at)}</span>
<span>·</span>
<span>{formatBytes(rec.size_bytes)}</span>
<div key={rec.id} className="rounded-xl border border-sky-200 bg-white">
<div className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Mic className="h-5 w-5" />
</div>
{rec.upload_error && (
<div className="mt-1 text-xs text-destructive">
{rec.upload_error}
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{rec.filename}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<span>{rec.username}</span>
<span>·</span>
<span>{rec.channel_name ?? rec.channel_id ?? "unknown"}</span>
<span>·</span>
<span>{formatDate(rec.created_at)}</span>
<span>·</span>
<span>{formatBytes(rec.size_bytes)}</span>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={deletingIds.has(rec.id)}
onClick={() => handleDelete(rec.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
<Badge
variant={
rec.upload_status === "uploaded"
? "success"
: rec.upload_status === "failed"
? "destructive"
: "secondary"
}
>
{rec.upload_status}
</Badge>
{rec.download_url && (
<>
{rec.upload_error && (
<div className="mt-1 text-xs text-destructive">
{rec.upload_error}
</div>
)}
{rec.transcription && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground italic">
{rec.transcription}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={deletingIds.has(rec.id)}
onClick={() => handleDelete(rec.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
<Badge
variant={
rec.upload_status === "uploaded"
? "success"
: rec.upload_status === "failed"
? "destructive"
: "secondary"
}
>
{rec.upload_status}
</Badge>
{rec.download_url && (
<a
href={rec.download_url}
download={rec.filename}
@@ -178,15 +180,15 @@ export function RecordingsSubPanel() {
>
<Download className="h-4 w-4" />
</a>
</>
)}
)}
</div>
</div>
{rec.download_url && (
<div className="-mt-2 px-4 pb-4">
<WaveformPlayer downloadUrl={rec.download_url} filename={rec.filename} />
</div>
)}
</div>
{rec.download_url && (
<div className="-mt-2 px-4 pb-4">
<WaveformPlayer downloadUrl={rec.download_url} filename={rec.filename} />
</div>
)}
))}
{hasMore && (
<div className="flex justify-center pt-2">
@@ -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;
}