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