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',
TRIGGER_PATTERN: /^@Andy\b/i,
DATA_DIR: '/tmp/nanoclaw-test-data',
CACHE_DIR: '/tmp/nanoclaw-test-cache',
}));
// Mock logger

View File

@@ -10,11 +10,12 @@ import {
TextChannel,
} 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 { logger } from '../logger.js';
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.
@@ -34,15 +35,54 @@ async function downloadImage(att: Attachment): Promise<string> {
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.
* 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(
att: Attachment,
apiKey: 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 {
// Mark as pending
fs.writeFileSync(pendingFile, process.pid.toString());
const res = await fetch(att.url);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer());
@@ -65,14 +105,25 @@ async function transcribeAudio(
throw new Error(`Whisper API ${whisperRes.status}: ${errText}`);
}
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(
{ file: filename, length: data.text.length },
'Audio transcribed',
'Audio transcribed + cached',
);
return `[Voice message transcription]: ${data.text}`;
return result;
} catch (err) {
logger.error({ err, file: 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';
@@ -511,12 +562,8 @@ export class DiscordChannel implements Channel {
// Separate into bulk-deletable (< 14 days) and old messages
const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
const recent = messages.filter(
(m) => m.createdTimestamp > twoWeeksAgo,
);
const old = messages.filter(
(m) => m.createdTimestamp <= twoWeeksAgo,
);
const recent = messages.filter((m) => m.createdTimestamp > twoWeeksAgo);
const old = messages.filter((m) => m.createdTimestamp <= twoWeeksAgo);
if (recent.size >= 2) {
await tc.bulkDelete(recent);