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:
Eyejoker
2026-03-14 01:40:35 +09:00
parent 7a726c335d
commit b8292e14a1
3 changed files with 60 additions and 10 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -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',