meeting-whisperer
Version:
CLI tool that transcribes audio/video files using AssemblyAI's API and outputs formatted markdown transcripts
585 lines β’ 24.3 kB
JavaScript
import { AssemblyAI } from 'assemblyai';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as path from 'path';
import * as os from 'os';
// Constants
const CONFIG_DIR = path.join(os.homedir(), '.assembly-whisperer');
const CONFIG_FILE = path.join(CONFIG_DIR, 'keys.json');
const TMP_DIR = path.join(CONFIG_DIR, 'tmp');
const DRAFT_FILE = path.join(TMP_DIR, 'draft.json');
export class AssemblyWhisperer {
client = null;
spinner = ora();
isInterrupted = false;
constructor() {
// Handle Ctrl+C gracefully
process.on('SIGINT', this.handleInterrupt.bind(this));
process.on('SIGTERM', this.handleInterrupt.bind(this));
}
async handleInterrupt() {
if (this.isInterrupted) {
// Force exit if already interrupted once
console.log(chalk.red('\nπ Force closing...'));
process.exit(1);
}
this.isInterrupted = true;
this.spinner.stop();
console.log(chalk.yellow('\nβΈοΈ Gracefully stopping... (Press Ctrl+C again to force quit)'));
console.log(chalk.blue('πΎ Your progress has been saved. Run the same command to resume.'));
// Give a moment for any pending saves
setTimeout(() => {
process.exit(0);
}, 100);
}
async run(inputFile) {
try {
if (!inputFile) {
inputFile = process.argv[2];
if (!inputFile) {
console.error(chalk.red('Error: Please provide an audio file path'));
console.log(chalk.yellow('Usage: npx tsx assembly-whisperer.ts /path/to/audio.mp4'));
process.exit(1);
}
}
// Check if file exists
if (!fsSync.existsSync(inputFile)) {
console.error(chalk.red('Error: Audio file does not exist'));
process.exit(1);
}
// Setup configuration
await this.setupConfig();
// Check for existing draft
const shouldResume = await this.checkForDraft(inputFile);
let draft;
if (shouldResume) {
draft = await this.loadDraft();
console.log(chalk.green('Resuming from previous session...'));
// Handle different resume scenarios
if (draft.stage === 'submitted' || draft.stage === 'waiting') {
console.log(chalk.blue('Checking transcription status...'));
draft = await this.waitForTranscription(draft);
}
}
else {
// Delete any existing draft
await this.deleteDraft();
// Process new transcription
draft = await this.processTranscription(inputFile);
}
// Interactive speaker identification
if (draft.stage === 'got_transcript') {
await this.identifySpeakers(draft);
}
// Generate markdown
await this.generateMarkdown(draft);
// Cleanup
await this.deleteDraft();
console.log(chalk.green('β
Transcript saved to:'), this.getOutputPath(draft.inputFile));
}
catch (error) {
this.spinner.stop();
if (error instanceof Error) {
console.error(chalk.red('Error:'), error.message);
}
else {
console.error(chalk.red('An unexpected error occurred'));
}
process.exit(1);
}
}
async setupConfig() {
// Ensure directories exist
await fs.mkdir(CONFIG_DIR, { recursive: true });
await fs.mkdir(TMP_DIR, { recursive: true });
let config = {};
// Try to load existing config
if (fsSync.existsSync(CONFIG_FILE)) {
try {
const configData = await fs.readFile(CONFIG_FILE, 'utf-8');
config = JSON.parse(configData);
}
catch (error) {
console.log(chalk.yellow('Warning: Could not parse existing config file'));
}
}
// Check if API key exists
if (!config.assemblyai_key) {
console.log(chalk.yellow('AssemblyAI API key not found.'));
const { apiKey } = await inquirer.prompt([
{
type: 'password',
name: 'apiKey',
message: 'Please enter your AssemblyAI API key:',
mask: '*'
}
]);
// Validate API key
await this.validateApiKey(apiKey);
config.assemblyai_key = apiKey;
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
console.log(chalk.green('β
API key saved successfully'));
}
this.client = new AssemblyAI({ apiKey: config.assemblyai_key });
}
async validateApiKey(apiKey) {
this.spinner.start('Validating API key...');
try {
const testClient = new AssemblyAI({ apiKey });
// Make a simple API call to validate
await testClient.transcripts.list({ limit: 1 });
this.spinner.stop();
}
catch (error) {
this.spinner.stop();
throw new Error('Invalid AssemblyAI API key');
}
}
async checkForDraft(inputFile) {
if (!fsSync.existsSync(DRAFT_FILE)) {
return false;
}
try {
const draftData = await fs.readFile(DRAFT_FILE, 'utf-8');
const draft = JSON.parse(draftData);
if (path.resolve(draft.inputFile) === path.resolve(inputFile)) {
const { resume } = await inquirer.prompt([
{
type: 'confirm',
name: 'resume',
message: 'Resume previous session?',
default: true
}
]);
return resume;
}
}
catch (error) {
console.log(chalk.yellow('Warning: Could not parse existing draft file'));
}
return false;
}
async loadDraft() {
const draftData = await fs.readFile(DRAFT_FILE, 'utf-8');
const draft = JSON.parse(draftData);
// Convert completedSpeakers back to Set (it's saved as array in JSON)
if (draft.completedSpeakers) {
draft.completedSpeakers = new Set(draft.completedSpeakers);
}
else {
draft.completedSpeakers = new Set();
}
return draft;
}
async saveDraft(draft) {
// Convert Set to Array for JSON serialization
const draftForSave = {
...draft,
completedSpeakers: Array.from(draft.completedSpeakers)
};
await fs.writeFile(DRAFT_FILE, JSON.stringify(draftForSave, null, 2));
}
async deleteDraft() {
if (fsSync.existsSync(DRAFT_FILE)) {
await fs.unlink(DRAFT_FILE);
}
}
async processTranscription(inputFile) {
if (!this.client)
throw new Error('AssemblyAI client not initialized');
// Check if we have a submitted job that's still processing
const existingDraft = await this.loadDraftIfExists();
if (existingDraft && existingDraft.stage === 'submitted' && existingDraft.jobId) {
console.log(chalk.blue('Found existing transcription job, checking status...'));
return await this.waitForTranscription(existingDraft);
}
this.spinner.start('Submitting transcription job...');
try {
const params = {
audio: inputFile,
speaker_labels: true
};
// Submit the job (non-blocking)
const job = await this.interruptibleOperation(() => this.client.transcripts.submit(params), 'Submitting job...');
if (this.isInterrupted)
return this.createInterruptedDraft(inputFile);
console.log(chalk.green(`β
Job submitted with ID: ${job.id}`));
// Create initial draft
const draft = {
inputFile,
stage: 'submitted',
jobId: job.id,
speakerMappings: {},
completedSpeakers: new Set()
};
await this.saveDraft(draft);
this.spinner.stop();
// Now wait for completion
return await this.waitForTranscription(draft);
}
catch (error) {
this.spinner.stop();
if (error instanceof Error) {
if (error.message.includes('network') || error.message.includes('fetch')) {
throw new Error('Failed to connect to AssemblyAI');
}
throw error;
}
throw new Error('Failed to submit transcription job');
}
}
async waitForTranscription(draft) {
if (!this.client || !draft.jobId) {
throw new Error('Missing client or job ID');
}
draft.stage = 'waiting';
await this.saveDraft(draft);
this.spinner.start('Waiting for transcription to complete...');
try {
// Custom polling with interruption checks
let transcript = await this.client.transcripts.get(draft.jobId);
while (transcript.status !== 'completed' && transcript.status !== 'error' && !this.isInterrupted) {
await this.sleep(3000); // Wait 3 seconds
if (this.isInterrupted) {
console.log(chalk.blue('\nπΎ Transcription will continue in background. Resume later with the same command.'));
return draft;
}
this.spinner.text = `Waiting for transcription... Status: ${transcript.status}`;
transcript = await this.client.transcripts.get(draft.jobId);
}
if (this.isInterrupted)
return draft;
if (transcript.status === 'error') {
const errorMessage = transcript.error || 'Unknown error';
throw new Error(`Transcription failed: ${errorMessage}`);
}
this.spinner.stop();
// Process segments
const segments = [];
const speakers = new Set();
if (transcript.utterances) {
for (const utterance of transcript.utterances) {
segments.push({
text: utterance.text,
start: utterance.start,
end: utterance.end,
speaker: utterance.speaker || 'Unknown'
});
speakers.add(utterance.speaker || 'Unknown');
}
}
const speakerArray = Array.from(speakers).sort();
const speakerMappings = {};
speakerArray.forEach(speaker => {
speakerMappings[speaker] = null;
});
const duration = Math.round(transcript.audio_duration || 0);
console.log(chalk.green(`β
Found ${speakerArray.length} speakers in ${this.formatDuration(duration * 1000)} of audio`));
// Update draft with transcript data
draft.stage = 'got_transcript';
draft.transcript = {
segments,
speakers: speakerArray
};
draft.speakerMappings = speakerMappings;
await this.saveDraft(draft);
return draft;
}
catch (error) {
this.spinner.stop();
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to complete transcription');
}
}
async loadDraftIfExists() {
if (!fsSync.existsSync(DRAFT_FILE)) {
return null;
}
try {
const draftData = await fs.readFile(DRAFT_FILE, 'utf-8');
const draft = JSON.parse(draftData);
// Convert completedSpeakers back to Set
if (draft.completedSpeakers) {
draft.completedSpeakers = new Set(draft.completedSpeakers);
}
return draft;
}
catch (error) {
return null;
}
}
async identifySpeakers(draft) {
if (!draft.transcript) {
throw new Error('No transcript data available');
}
const allSpeakers = draft.transcript.speakers;
let currentRound = 0;
const maxRounds = 3; // Prevent infinite cycling
while (draft.completedSpeakers.size < allSpeakers.length && currentRound < maxRounds) {
currentRound++;
console.log(chalk.blue(`\n=== Round ${currentRound} of Speaker Identification ===`));
const remainingSpeakers = allSpeakers.filter(speaker => !draft.completedSpeakers.has(speaker));
if (remainingSpeakers.length === 0)
break;
console.log(chalk.yellow(`Remaining speakers: ${remainingSpeakers.join(', ')}`));
for (const speaker of remainingSpeakers) {
console.log(chalk.blue(`\nIdentifying ${speaker}`));
if (draft.completedSpeakers.size > 0) {
console.log(chalk.gray('Already completed:'));
allSpeakers.forEach(spk => {
if (draft.completedSpeakers.has(spk)) {
const name = draft.speakerMappings[spk];
if (name) {
console.log(chalk.gray(` ${spk} β ${name}`));
}
else {
console.log(chalk.gray(` ${spk} β (skipped)`));
}
}
});
}
const action = await this.identifySingleSpeaker(draft, speaker);
if (action === 'identify' || action === 'skip') {
draft.completedSpeakers.add(speaker);
}
// If action is 'skip_for_now', we don't add to completedSpeakers
await this.saveDraft(draft);
}
}
// Final check - ask about any remaining unidentified speakers
const unidentified = allSpeakers.filter(speaker => !draft.completedSpeakers.has(speaker) || !draft.speakerMappings[speaker]);
if (unidentified.length > 0) {
console.log(chalk.yellow(`\nStill have ${unidentified.length} unidentified speakers: ${unidentified.join(', ')}`));
console.log(chalk.gray('These will appear as generic speaker names in the transcript.'));
}
draft.stage = 'user_finished_labelling';
await this.saveDraft(draft);
}
async identifySingleSpeaker(draft, speaker) {
if (!draft.transcript) {
throw new Error('No transcript data available');
}
const speakerSegments = draft.transcript.segments
.filter(seg => seg.speaker === speaker)
.sort((a, b) => (b.end - b.start) - (a.end - a.start)); // Sort by length, longest first
let exampleOffset = 0;
const examplesPerPage = 3;
while (true) {
// Show examples
const examples = speakerSegments.slice(exampleOffset, exampleOffset + examplesPerPage);
if (examples.length === 0) {
console.log(chalk.yellow('No more examples available.'));
exampleOffset = 0; // Reset to beginning
continue;
}
console.log(); // Empty line
examples.forEach((segment, idx) => {
console.log(chalk.cyan(`Example ${exampleOffset + idx + 1} of ${speakerSegments.length}:\n`));
const context = this.getContextForSegment(draft, segment);
context.forEach(line => console.log(line));
console.log(); // Empty line
});
const choices = [
{ name: 'π€ Identify this speaker', value: 'identify' },
{ name: 'π Show more examples', value: 'more' },
{ name: 'βΈοΈ Skip for now (try again later)', value: 'skip_for_now' },
{ name: 'βοΈ Skip this speaker (permanently)', value: 'skip' }
];
const { action } = await inquirer.prompt({
type: 'list',
name: 'action',
message: 'What would you like to do?',
choices
});
if (action === 'more') {
exampleOffset += examplesPerPage;
if (exampleOffset >= speakerSegments.length) {
exampleOffset = 0; // Wrap around
}
}
else if (action === 'skip') {
console.log(chalk.yellow(`βοΈ Permanently skipped ${speaker}`));
return 'skip';
}
else if (action === 'skip_for_now') {
console.log(chalk.blue(`βΈοΈ Will try ${speaker} again later`));
return 'skip_for_now';
}
else if (action === 'identify') {
const { name } = await inquirer.prompt({
type: 'input',
name: 'name',
message: `Enter the name for ${speaker}:`,
validate: (input) => {
if (input.trim().length === 0) {
return 'Please enter a valid name';
}
return true;
}
});
draft.speakerMappings[speaker] = name.trim();
console.log(chalk.green(`β
${speaker} identified as: ${name.trim()}`));
return 'identify';
}
}
}
getContextForSegment(draft, targetSegment) {
if (!draft.transcript) {
return [`β [${targetSegment.speaker}]: "${targetSegment.text}" β IDENTIFY THIS`];
}
const allSegments = draft.transcript.segments.sort((a, b) => a.start - b.start);
const targetIndex = allSegments.findIndex(seg => seg.start === targetSegment.start && seg.speaker === targetSegment.speaker);
const lines = [];
// Previous speaker
if (targetIndex > 0) {
const prevSegment = allSegments[targetIndex - 1];
if (prevSegment) {
const prevSpeaker = this.getSpeakerDisplayName(draft, prevSegment.speaker);
lines.push(chalk.gray(`[${prevSpeaker}]: "${prevSegment.text}"`));
}
}
// Current speaker (highlighted)
const currentSpeaker = this.getSpeakerDisplayName(draft, targetSegment.speaker);
lines.push(chalk.yellow(`β [${currentSpeaker}]: "${targetSegment.text}"`) + chalk.red(' β IDENTIFY THIS'));
// Next speaker
if (targetIndex < allSegments.length - 1) {
const nextSegment = allSegments[targetIndex + 1];
if (nextSegment) {
const nextSpeaker = this.getSpeakerDisplayName(draft, nextSegment.speaker);
lines.push(chalk.gray(`[${nextSpeaker}]: "${nextSegment.text}"`));
}
}
return lines;
}
getSpeakerDisplayName(draft, speaker) {
return draft.speakerMappings[speaker] || speaker;
}
async generateMarkdown(draft) {
if (!draft.transcript) {
throw new Error('No transcript data available');
}
this.spinner.start('Generating transcript...');
try {
const outputPath = this.getOutputPath(draft.inputFile);
const fileName = path.basename(draft.inputFile);
// Calculate duration
const segments = draft.transcript.segments || [];
const totalDuration = segments.length > 0
? Math.max(...segments.map(s => s.end))
: 0;
const durationStr = this.formatDuration(totalDuration);
// Get current date
const now = new Date();
const dateStr = now.toISOString().split('T')[0];
const timeStr = now.toTimeString().split(' ')[0]?.substring(0, 5) || '00:00';
let markdown = `# Meeting Transcript\n\n`;
markdown += `*File: ${fileName}*\n`;
markdown += `*Duration: ${durationStr}*\n`;
markdown += `*Processed: ${dateStr} at ${timeStr}*\n\n`;
// Speakers section
markdown += `## Speakers\n`;
draft.transcript.speakers.forEach(speaker => {
const name = draft.speakerMappings[speaker];
if (name) {
markdown += `- **${name}**\n`;
}
else {
markdown += `- **${speaker}** (unidentified)\n`;
}
});
markdown += `\n## Transcript\n\n`;
// Sort segments by start time
const sortedSegments = [...draft.transcript.segments].sort((a, b) => a.start - b.start);
sortedSegments.forEach(segment => {
const timeStr = this.formatTime(segment.start);
const speakerName = this.getSpeakerDisplayName(draft, segment.speaker);
markdown += `[${timeStr}] **${speakerName}**: ${segment.text}\n\n`;
});
await fs.writeFile(outputPath, markdown);
this.spinner.stop();
}
catch (error) {
this.spinner.stop();
throw new Error('Failed to generate markdown file');
}
}
getOutputPath(inputFile) {
const dir = path.dirname(inputFile);
const name = path.basename(inputFile, path.extname(inputFile));
return path.join(dir, `${name}-transcript.md`);
}
formatTime(ms) {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
formatDuration(ms) {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
if (minutes === 1) {
return '1 minute';
}
else {
return `${minutes} minutes`;
}
}
async interruptibleOperation(operation, description) {
return new Promise((resolve, reject) => {
if (this.isInterrupted) {
reject(new Error('Operation interrupted'));
return;
}
const timeoutId = setTimeout(() => {
if (this.isInterrupted) {
reject(new Error('Operation interrupted'));
}
}, 100);
operation()
.then(result => {
clearTimeout(timeoutId);
resolve(result);
})
.catch(error => {
clearTimeout(timeoutId);
reject(error);
});
});
}
createInterruptedDraft(inputFile) {
return {
inputFile,
stage: 'submitted',
speakerMappings: {},
completedSpeakers: new Set()
};
}
async sleep(ms) {
return new Promise(resolve => {
const timeout = setTimeout(resolve, ms);
// Check for interruption every 100ms
const checkInterval = setInterval(() => {
if (this.isInterrupted) {
clearTimeout(timeout);
clearInterval(checkInterval);
resolve();
}
}, 100);
setTimeout(() => {
clearInterval(checkInterval);
}, ms);
});
}
}
// Run the CLI
if (import.meta.url === `file://${process.argv[1]}`) {
const whisperer = new AssemblyWhisperer();
whisperer.run();
}
//# sourceMappingURL=assembly-whisperer.js.map