feat: shared transcription cache to avoid duplicate Whisper API calls
Both services (nanoclaw + nanoclaw-codex) now share cache/transcriptions/ directory. When one service transcribes audio, the other reads from cache instead of making a second Whisper API call. Uses .pending file to coordinate concurrent transcription attempts.
This commit is contained in:
@@ -13,6 +13,7 @@ vi.mock('../config.js', () => ({
|
|||||||
ASSISTANT_NAME: 'Andy',
|
ASSISTANT_NAME: 'Andy',
|
||||||
TRIGGER_PATTERN: /^@Andy\b/i,
|
TRIGGER_PATTERN: /^@Andy\b/i,
|
||||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||||
|
CACHE_DIR: '/tmp/nanoclaw-test-cache',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock logger
|
// Mock logger
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
TextChannel,
|
TextChannel,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import { ASSISTANT_NAME, 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';
|
||||||
|
|
||||||
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
||||||
|
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a Discord image attachment to local disk.
|
* Download a Discord image attachment to local disk.
|
||||||
@@ -34,15 +35,54 @@ async function downloadImage(att: Attachment): Promise<string> {
|
|||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a pending transcription from the other service (poll cache file).
|
||||||
|
* Returns the cached text, or null if timeout.
|
||||||
|
*/
|
||||||
|
async function waitForPendingTranscription(
|
||||||
|
cacheFile: string,
|
||||||
|
timeoutMs = 15000,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
if (fs.existsSync(cacheFile)) {
|
||||||
|
return fs.readFileSync(cacheFile, 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transcribe an audio attachment via OpenAI Whisper API.
|
* Transcribe an audio attachment via OpenAI Whisper API.
|
||||||
* Returns the transcribed text, or a fallback placeholder on failure.
|
* Uses shared file cache so both services don't duplicate API calls.
|
||||||
*/
|
*/
|
||||||
async function transcribeAudio(
|
async function transcribeAudio(
|
||||||
att: Attachment,
|
att: Attachment,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
fs.mkdirSync(TRANSCRIPTION_CACHE_DIR, { recursive: true });
|
||||||
|
const cacheFile = path.join(TRANSCRIPTION_CACHE_DIR, `${att.id}.txt`);
|
||||||
|
const pendingFile = path.join(TRANSCRIPTION_CACHE_DIR, `${att.id}.pending`);
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (fs.existsSync(cacheFile)) {
|
||||||
|
logger.info({ attId: att.id }, 'Transcription cache hit');
|
||||||
|
return fs.readFileSync(cacheFile, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another service is already transcribing — wait for result
|
||||||
|
if (fs.existsSync(pendingFile)) {
|
||||||
|
logger.info({ attId: att.id }, 'Waiting for pending transcription');
|
||||||
|
const cached = await waitForPendingTranscription(cacheFile);
|
||||||
|
if (cached) return cached;
|
||||||
|
// Timeout — fall through and transcribe ourselves
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Mark as pending
|
||||||
|
fs.writeFileSync(pendingFile, process.pid.toString());
|
||||||
|
|
||||||
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());
|
||||||
@@ -65,14 +105,25 @@ async function transcribeAudio(
|
|||||||
throw new Error(`Whisper API ${whisperRes.status}: ${errText}`);
|
throw new Error(`Whisper API ${whisperRes.status}: ${errText}`);
|
||||||
}
|
}
|
||||||
const data = (await whisperRes.json()) as { text: string };
|
const data = (await whisperRes.json()) as { text: string };
|
||||||
|
const result = `[Voice message transcription]: ${data.text}`;
|
||||||
|
|
||||||
|
// Save to cache for the other service
|
||||||
|
fs.writeFileSync(cacheFile, result);
|
||||||
logger.info(
|
logger.info(
|
||||||
{ file: filename, length: data.text.length },
|
{ file: filename, length: data.text.length },
|
||||||
'Audio transcribed',
|
'Audio transcribed + cached',
|
||||||
);
|
);
|
||||||
return `[Voice message transcription]: ${data.text}`;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err, file: att.name }, 'Audio transcription failed');
|
logger.error({ err, file: att.name }, 'Audio transcription failed');
|
||||||
return `[Audio: ${att.name || 'audio'} (transcription failed)]`;
|
return `[Audio: ${att.name || 'audio'} (transcription failed)]`;
|
||||||
|
} finally {
|
||||||
|
// Clean up pending marker
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(pendingFile);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
import { registerChannel, ChannelOpts } from './registry.js';
|
import { registerChannel, ChannelOpts } from './registry.js';
|
||||||
@@ -511,12 +562,8 @@ export class DiscordChannel implements Channel {
|
|||||||
|
|
||||||
// Separate into bulk-deletable (< 14 days) and old messages
|
// Separate into bulk-deletable (< 14 days) and old messages
|
||||||
const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
||||||
const recent = messages.filter(
|
const recent = messages.filter((m) => m.createdTimestamp > twoWeeksAgo);
|
||||||
(m) => m.createdTimestamp > twoWeeksAgo,
|
const old = messages.filter((m) => m.createdTimestamp <= twoWeeksAgo);
|
||||||
);
|
|
||||||
const old = messages.filter(
|
|
||||||
(m) => m.createdTimestamp <= twoWeeksAgo,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (recent.size >= 2) {
|
if (recent.size >= 2) {
|
||||||
await tc.bulkDelete(recent);
|
await tc.bulkDelete(recent);
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export const GROUPS_DIR = path.resolve(
|
|||||||
export const DATA_DIR = path.resolve(
|
export const DATA_DIR = path.resolve(
|
||||||
process.env.NANOCLAW_DATA_DIR || path.join(PROJECT_ROOT, 'data'),
|
process.env.NANOCLAW_DATA_DIR || path.join(PROJECT_ROOT, 'data'),
|
||||||
);
|
);
|
||||||
|
// Shared cache directory (same across both services for dedup)
|
||||||
|
export const CACHE_DIR = path.join(PROJECT_ROOT, 'cache');
|
||||||
|
|
||||||
export const AGENT_TIMEOUT = parseInt(
|
export const AGENT_TIMEOUT = parseInt(
|
||||||
process.env.AGENT_TIMEOUT || '1800000',
|
process.env.AGENT_TIMEOUT || '1800000',
|
||||||
|
|||||||
Reference in New Issue
Block a user