@calmhive/calmhive-cli
Version:
🐝 Intelligent Claude CLI wrapper with rule injection, AFk background processing, voice interface, and adaptive retry
349 lines (308 loc) • 9.14 kB
JavaScript
// lib/voice/voice-manager.js
// V3 Voice Manager - Local speech synthesis using mlx-audio for M3 MacBook
const EventEmitter = require('events');
const { exec, spawn } = require('child_process');
const { promisify } = require('util');
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
const execAsync = promisify(exec);
class VoiceManager extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
voice: options.voice || 'af_heart',
speed: options.speed || 1.0,
outputDir: options.outputDir || path.join(os.homedir(), '.mlx_audio/outputs'),
...options
};
this.isListening = false;
this.mlxInstalled = null;
this.execAsync = execAsync; // Make it available to tests
this.checkMLXInstallation();
}
/**
* Check if mlx-audio is installed
*/
async checkMLXInstallation() {
return new Promise((resolve) => {
exec('which mlx_audio.tts.generate', (error) => {
this.mlxInstalled = !error;
if (!this.mlxInstalled) {
this.emit('warning', 'mlx-audio not installed. Run: pip install mlx-audio');
}
resolve(this.mlxInstalled);
});
});
}
/**
* Generate speech from text using mlx-audio
*/
async speak(text, options = {}) {
if (!text || text.trim() === '') {
throw new Error('No text provided for speech synthesis');
}
if (!this.mlxInstalled) {
await this.checkMLXInstallation();
if (!this.mlxInstalled) {
throw new Error('mlx-audio not installed. Please run: pip install mlx-audio');
}
}
const voice = options.voice || this.options.voice;
const speed = options.speed || this.options.speed;
const filePrefix = options.filePrefix || `calmhive_${Date.now()}`;
return new Promise((resolve, reject) => {
const args = [
'-m', 'mlx_audio.tts.generate',
'--text', text,
'--voice', voice,
'--speed', speed.toString(),
'--file_prefix', filePrefix
];
const pythonProcess = spawn('python', args, {
cwd: this.options.outputDir
});
let output = '';
let errorOutput = '';
pythonProcess.stdout.on('data', (data) => {
output += data.toString();
});
pythonProcess.stderr.on('data', (data) => {
errorOutput += data.toString();
});
pythonProcess.on('close', (code) => {
if (code !== 0) {
reject(new Error(`TTS generation failed: ${errorOutput}`));
} else {
const audioFile = path.join(this.options.outputDir, `${filePrefix}.wav`);
this.emit('speech-complete', {
text,
file: audioFile,
voice,
speed
});
// Auto-play if requested
if (options.autoPlay) {
this.playAudio(audioFile);
}
resolve(audioFile);
}
});
});
}
/**
* Play audio file using system player
*/
async playAudio(audioFile) {
return new Promise((resolve, reject) => {
// Use afplay on macOS
exec(`afplay "${audioFile}"`, (error) => {
if (error) {
reject(error);
} else {
this.emit('playback-complete', audioFile);
resolve();
}
});
});
}
/**
* Process voice command and convert to calmhive command
*/
analyzeCommand(transcript) {
const lowerText = transcript.toLowerCase();
// Command patterns
const patterns = {
afk: /(?:run|start|execute|do)\s+(?:in\s+)?(?:background|afk)|afk\s+(?:mode|task)?/i,
fix: /fix(?:ing)?\s+(?:the\s+)?|repair|debug|solve|resolve/i,
do: /(?:please\s+)?(?:do|perform|execute|complete|handle)\s+/i,
search: /(?:search|find|look\s+for|locate)\s+/i,
help: /(?:help|what\s+can\s+you\s+do|how\s+do\s+i)/i,
stop: /(?:stop|cancel|abort|quit|exit)/i,
status: /(?:status|what'?s\s+happening|progress|update)/i,
think: /(?:think|analyze|consider|evaluate)\s+(?:about\s+)?/i,
tui: /(?:show|open|launch)\s+(?:the\s+)?(?:interface|tui|ui)/i
};
// Determine command type
let commandType = 'do'; // default
let commandText = transcript;
for (const [type, pattern] of Object.entries(patterns)) {
if (pattern.test(lowerText)) {
commandType = type;
commandText = transcript.replace(pattern, '').trim();
break;
}
}
return {
type: commandType,
text: commandText,
original: transcript
};
}
/**
* Convert text to speech and execute as command
*/
async executeVoiceCommand(transcript) {
const command = this.analyzeCommand(transcript);
// Generate acknowledgment
const ackText = this.getAcknowledgment(command);
await this.speak(ackText, { autoPlay: true });
// Build command line
let cmdLine = '';
switch (command.type) {
case 'afk':
cmdLine = `calmhive afk start "${command.text}"`;
break;
case 'fix':
cmdLine = `calmhive fix "${command.text}"`;
break;
case 'do':
cmdLine = `calmhive do "${command.text}"`;
break;
case 'search':
cmdLine = `calmhive search "${command.text}"`;
break;
case 'status':
cmdLine = 'calmhive afk status';
break;
case 'tui':
cmdLine = 'calmhive tui';
break;
case 'help':
cmdLine = 'calmhive help';
break;
default:
cmdLine = `calmhive do "${command.text}"`;
}
this.emit('command-execute', {
command: command,
cmdLine: cmdLine
});
return cmdLine;
}
/**
* Get acknowledgment text for command
*/
getAcknowledgment(command) {
const acks = {
afk: [
'Starting background task',
'Running in AFk mode',
'Processing in background'
],
fix: [
'Looking into the issue',
'Analyzing the problem',
'Working on a fix'
],
do: [
'Working on it',
'Processing your request',
'Getting that done'
],
status: [
'Checking status',
'Getting current progress',
'Retrieving status'
]
};
const options = acks[command.type] || ['Processing'];
return options[Math.floor(Math.random() * options.length)];
}
/**
* List available voices
*/
getAvailableVoices() {
return [
{ id: 'af_heart', name: 'AF Heart', gender: 'female' },
{ id: 'af_nova', name: 'AF Nova', gender: 'female' },
{ id: 'af_bella', name: 'AF Bella', gender: 'female' },
{ id: 'bf_emma', name: 'BF Emma', gender: 'female' },
{ id: 'am_adam', name: 'AM Adam', gender: 'male' },
{ id: 'bm_george', name: 'BM George', gender: 'male' }
];
}
/**
* Test voice setup
*/
async testVoiceSetup() {
try {
await this.speak('Calmhive voice system initialized', {
autoPlay: true
});
return {
tts: true,
status: 'Voice system ready (mlx-audio)'
};
} catch (error) {
return {
tts: false,
status: 'Voice system not available',
error: error.message
};
}
}
/**
* Start listening with RealtimeSTT (if available)
* Falls back to warning if not installed
*/
async startListening() {
try {
// Check if RealtimeSTT is available (from V1)
const { spawn } = require('child_process');
const checkSTT = spawn('python3', ['-c', 'import RealtimeSTT']);
await new Promise((resolve, reject) => {
checkSTT.on('close', (code) => {
if (code === 0) {
this.emit('info', 'RealtimeSTT available - use voice start command');
resolve();
} else {
reject(new Error('RealtimeSTT not installed'));
}
});
});
this.isListening = true;
this.emit('listening-start');
} catch (error) {
this.emit('warning', 'RealtimeSTT not installed. Install with: pip install RealtimeSTT');
this.isListening = false;
}
}
/**
* Stop listening
*/
stopListening() {
this.isListening = false;
this.emit('listening-stop');
}
/**
* Get conversation history (V1 compatibility)
*/
async getConversationHistory() {
const conversationDir = path.join(os.homedir(), '.claude/calmhive/lib/voice/consolidated/output');
try {
const files = await fs.readdir(conversationDir);
return files.filter(f => f.endsWith('.yml'));
} catch (error) {
return [];
}
}
/**
* Resume conversation (V1 compatibility)
*/
async resumeConversation(conversationId) {
const conversationPath = path.join(
os.homedir(),
'.claude/calmhive/lib/voice/consolidated/output',
`${conversationId}.yml`
);
try {
const content = await fs.readFile(conversationPath, 'utf8');
this.emit('conversation-loaded', { id: conversationId, content });
return content;
} catch (error) {
throw new Error(`Conversation ${conversationId} not found`);
}
}
}
module.exports = VoiceManager;