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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user