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), .default(50),
PISCINA_MAX_THREADS: z.coerce.number().int().positive().optional(), 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 ───────────────────────────────────────────────
OPENAI_MODERATION_API_KEY: z.string().optional(), OPENAI_MODERATION_API_KEY: z.string().optional(),
OPENAI_MODERATION_BASE_URL: z OPENAI_MODERATION_BASE_URL: z
+1
View File
@@ -167,6 +167,7 @@ export interface VoiceRecordingUploadData {
upload_status: string; upload_status: string;
created_at: number; created_at: number;
uploaded_at: number; uploaded_at: number;
transcription?: string | null;
} }
export interface AnalysisQueueStatus { export interface AnalysisQueueStatus {
@@ -17,6 +17,7 @@ export interface RecordingRow {
download_url: string | null; download_url: string | null;
upload_status: string; upload_status: string;
upload_error: string | null; upload_error: string | null;
transcription: string | null;
created_at: number; created_at: number;
uploaded_at: number | null; uploaded_at: number | null;
duration_bytes: number; duration_bytes: number;
@@ -59,7 +60,7 @@ export class RecordingsService {
SELECT SELECT
id, user_id, username, avatar_url, guild_id, channel_id, id, user_id, username, avatar_url, guild_id, channel_id,
channel_name, filename, size_bytes, download_url, 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 COALESCE(size_bytes, 0) AS duration_bytes
FROM voice_recordings FROM voice_recordings
${sql.raw(whereClause)} ${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); cacheMetadata(userId, result);
return 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) // Path helpers (was segment.ts)
@@ -6,8 +6,10 @@ import {
insertVoiceRecording, insertVoiceRecording,
updateVoiceRecordingAsFailed, updateVoiceRecordingAsFailed,
updateVoiceRecordingAsUploaded, updateVoiceRecordingAsUploaded,
updateVoiceRecordingTranscription,
} from "../../../shared/database/voiceRecordingRepo.js"; } from "../../../shared/database/voiceRecordingRepo.js";
import { uploadToTele } from "../teleUpload.js"; import { uploadToTele } from "../teleUpload.js";
import { transcribeRecording } from "../voiceTranscriber.js";
const logger = createChildLogger("recording-uploader"); 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) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
logger.error({ id, error: errorMsg }, "Failed to upload voice recording"); 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"), upload_error: pgText("upload_error"),
created_at: pgBigint("created_at", { mode: "number" }).notNull(), created_at: pgBigint("created_at", { mode: "number" }).notNull(),
uploaded_at: pgBigint("uploaded_at", { mode: "number" }), uploaded_at: pgBigint("uploaded_at", { mode: "number" }),
transcription: pgText("transcription"),
}, },
(table) => ({ (table) => ({
userIdIdx: pgIndex("idx_voice_recordings_user_id").on(table.user_id), 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( export async function listVoiceRecordings(
limit = 100, limit = 100,
): Promise<VoiceRecording[]> { ): Promise<VoiceRecording[]> {
@@ -1,7 +1,7 @@
// ─── Recordings Sub-Panel ── // ─── Recordings Sub-Panel ──
import { Download, Mic, Trash2 } from "lucide-react"; 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 type { VoiceRecording } from "../../../shared/api/client";
import { deleteRecording, listRecordings } from "../../../shared/api/client"; import { deleteRecording, listRecordings } from "../../../shared/api/client";
import { formatBytes, formatDate } from "../../../shared/lib/utils"; import { formatBytes, formatDate } from "../../../shared/lib/utils";
@@ -124,53 +124,55 @@ export function RecordingsSubPanel() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{recordings.map((rec) => ( {recordings.map((rec) => (
<div <div key={rec.id} className="rounded-xl border border-sky-200 bg-white">
key={rec.id} <div className="flex items-center gap-4 p-4">
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 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> </div>
{rec.upload_error && ( <div className="min-w-0 flex-1">
<div className="mt-1 text-xs text-destructive"> <div className="truncate font-medium">{rec.filename}</div>
{rec.upload_error} <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>
)} {rec.upload_error && (
</div> <div className="mt-1 text-xs text-destructive">
<div className="flex shrink-0 items-center gap-2"> {rec.upload_error}
<Button </div>
size="sm" )}
variant="ghost" {rec.transcription && (
disabled={deletingIds.has(rec.id)} <div className="mt-1 line-clamp-2 text-xs text-muted-foreground italic">
onClick={() => handleDelete(rec.id)} {rec.transcription}
className="text-muted-foreground hover:text-destructive" </div>
> )}
<Trash2 className="h-4 w-4" /> </div>
</Button> <div className="flex shrink-0 items-center gap-2">
<Badge <Button
variant={ size="sm"
rec.upload_status === "uploaded" variant="ghost"
? "success" disabled={deletingIds.has(rec.id)}
: rec.upload_status === "failed" onClick={() => handleDelete(rec.id)}
? "destructive" className="text-muted-foreground hover:text-destructive"
: "secondary" >
} <Trash2 className="h-4 w-4" />
> </Button>
{rec.upload_status} <Badge
</Badge> variant={
{rec.download_url && ( rec.upload_status === "uploaded"
<> ? "success"
: rec.upload_status === "failed"
? "destructive"
: "secondary"
}
>
{rec.upload_status}
</Badge>
{rec.download_url && (
<a <a
href={rec.download_url} href={rec.download_url}
download={rec.filename} download={rec.filename}
@@ -178,15 +180,15 @@ export function RecordingsSubPanel() {
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</a> </a>
</> )}
)} </div>
</div> </div>
{rec.download_url && (
<div className="-mt-2 px-4 pb-4">
<WaveformPlayer downloadUrl={rec.download_url} filename={rec.filename} />
</div>
)}
</div> </div>
{rec.download_url && (
<div className="-mt-2 px-4 pb-4">
<WaveformPlayer downloadUrl={rec.download_url} filename={rec.filename} />
</div>
)}
))} ))}
{hasMore && ( {hasMore && (
<div className="flex justify-center pt-2"> <div className="flex justify-center pt-2">
@@ -258,6 +258,7 @@ export interface VoiceRecording {
download_url: string | null; download_url: string | null;
upload_status: "pending" | "uploaded" | "failed"; upload_status: "pending" | "uploaded" | "failed";
upload_error: string | null; upload_error: string | null;
transcription?: string | null;
created_at: number; created_at: number;
uploaded_at: number | null; uploaded_at: number | null;
} }