feat: switch audio transcription from OpenAI Whisper to Groq Whisper

Groq runs the same whisper-large-v3-turbo model at 200x+ real-time speed.
Falls back to OpenAI Whisper if GROQ_API_KEY is not set.
Logs elapsed time per transcription for monitoring.
This commit is contained in:
Eyejoker
2026-03-14 01:51:38 +09:00
parent b8292e14a1
commit b8421b659d

View File

@@ -10,7 +10,12 @@ import {
TextChannel, TextChannel,
} from 'discord.js'; } from 'discord.js';
import { ASSISTANT_NAME, CACHE_DIR, DATA_DIR, TRIGGER_PATTERN } from '../config.js'; import {
ASSISTANT_NAME,
CACHE_DIR,
DATA_DIR,
TRIGGER_PATTERN,
} from '../config.js';
import { readEnvFile } from '../env.js'; import { readEnvFile } from '../env.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
@@ -54,13 +59,10 @@ async function waitForPendingTranscription(
} }
/** /**
* Transcribe an audio attachment via OpenAI Whisper API. * Transcribe an audio attachment via Groq Whisper (primary) or OpenAI Whisper (fallback).
* Uses shared file cache so both services don't duplicate API calls. * Uses shared file cache so both services don't duplicate API calls.
*/ */
async function transcribeAudio( async function transcribeAudio(att: Attachment): Promise<string> {
att: Attachment,
apiKey: string,
): Promise<string> {
fs.mkdirSync(TRANSCRIPTION_CACHE_DIR, { recursive: true }); fs.mkdirSync(TRANSCRIPTION_CACHE_DIR, { recursive: true });
const cacheFile = path.join(TRANSCRIPTION_CACHE_DIR, `${att.id}.txt`); const cacheFile = path.join(TRANSCRIPTION_CACHE_DIR, `${att.id}.txt`);
const pendingFile = path.join(TRANSCRIPTION_CACHE_DIR, `${att.id}.pending`); const pendingFile = path.join(TRANSCRIPTION_CACHE_DIR, `${att.id}.pending`);
@@ -83,34 +85,59 @@ async function transcribeAudio(
// Mark as pending // Mark as pending
fs.writeFileSync(pendingFile, process.pid.toString()); fs.writeFileSync(pendingFile, process.pid.toString());
const start = Date.now();
const res = await fetch(att.url); const res = await fetch(att.url);
if (!res.ok) throw new Error(`Download failed: ${res.status}`); if (!res.ok) throw new Error(`Download failed: ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer()); const buffer = Buffer.from(await res.arrayBuffer());
const filename = att.name || 'audio.ogg';
// Pick provider: Groq (fast) > OpenAI (fallback)
const envVars = readEnvFile(['GROQ_API_KEY', 'OPENAI_API_KEY']);
const groqKey =
process.env.GROQ_API_KEY || envVars.GROQ_API_KEY || '';
const openaiKey =
process.env.OPENAI_API_KEY || envVars.OPENAI_API_KEY || '';
let apiUrl: string;
let apiKeyToUse: string;
let model: string;
let provider: string;
if (groqKey) {
apiUrl = 'https://api.groq.com/openai/v1/audio/transcriptions';
apiKeyToUse = groqKey;
model = 'whisper-large-v3-turbo';
provider = 'groq';
} else if (openaiKey) {
apiUrl = 'https://api.openai.com/v1/audio/transcriptions';
apiKeyToUse = openaiKey;
model = 'whisper-1';
provider = 'openai';
} else {
return `[Audio: ${filename} (no transcription API key)]`;
}
const form = new FormData(); const form = new FormData();
const filename = att.name || 'audio.ogg';
form.append('file', new Blob([buffer]), filename); form.append('file', new Blob([buffer]), filename);
form.append('model', 'whisper-1'); form.append('model', model);
const whisperRes = await fetch( const whisperRes = await fetch(apiUrl, {
'https://api.openai.com/v1/audio/transcriptions',
{
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` }, headers: { Authorization: `Bearer ${apiKeyToUse}` },
body: form, body: form,
}, });
);
if (!whisperRes.ok) { if (!whisperRes.ok) {
const errText = await whisperRes.text(); const errText = await whisperRes.text();
throw new Error(`Whisper API ${whisperRes.status}: ${errText}`); throw new Error(`${provider} Whisper ${whisperRes.status}: ${errText}`);
} }
const data = (await whisperRes.json()) as { text: string }; const data = (await whisperRes.json()) as { text: string };
const elapsed = Date.now() - start;
const result = `[Voice message transcription]: ${data.text}`; const result = `[Voice message transcription]: ${data.text}`;
// Save to cache for the other service // Save to cache for the other service
fs.writeFileSync(cacheFile, result); fs.writeFileSync(cacheFile, result);
logger.info( logger.info(
{ file: filename, length: data.text.length }, { file: filename, length: data.text.length, provider, elapsed },
'Audio transcribed + cached', 'Audio transcribed + cached',
); );
return result; return result;
@@ -223,15 +250,11 @@ export class DiscordChannel implements Channel {
// Handle attachments — transcribe audio, placeholder for others // Handle attachments — transcribe audio, placeholder for others
if (message.attachments.size > 0) { if (message.attachments.size > 0) {
const openaiKey =
process.env.OPENAI_API_KEY ||
readEnvFile(['OPENAI_API_KEY']).OPENAI_API_KEY ||
'';
const attachmentDescriptions = await Promise.all( const attachmentDescriptions = await Promise.all(
[...message.attachments.values()].map(async (att) => { [...message.attachments.values()].map(async (att) => {
const contentType = att.contentType || ''; const contentType = att.contentType || '';
if (contentType.startsWith('audio/') && openaiKey) { if (contentType.startsWith('audio/')) {
return transcribeAudio(att, openaiKey); return transcribeAudio(att);
} else if (contentType.startsWith('image/')) { } else if (contentType.startsWith('image/')) {
try { try {
const imgPath = await downloadImage(att); const imgPath = await downloadImage(att);