@eldrforge/kodrdriv 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.kodrdriv/context/content.md +7 -1
- package/RELEASE_NOTES.md +14 -0
- package/dist/arguments.js +332 -9
- package/dist/arguments.js.map +1 -1
- package/dist/commands/audio-commit.js +666 -0
- package/dist/commands/audio-commit.js.map +1 -0
- package/dist/commands/audio-review.js +677 -0
- package/dist/commands/audio-review.js.map +1 -0
- package/dist/commands/clean.js +36 -0
- package/dist/commands/clean.js.map +1 -0
- package/dist/commands/commit.js +32 -7
- package/dist/commands/commit.js.map +1 -1
- package/dist/commands/publish.js +15 -7
- package/dist/commands/publish.js.map +1 -1
- package/dist/commands/release.js +31 -3
- package/dist/commands/release.js.map +1 -1
- package/dist/commands/review.js +152 -0
- package/dist/commands/review.js.map +1 -0
- package/dist/constants.js +90 -59
- package/dist/constants.js.map +1 -1
- package/dist/content/diff.js +155 -1
- package/dist/content/diff.js.map +1 -1
- package/dist/content/issues.js +240 -0
- package/dist/content/issues.js.map +1 -0
- package/dist/content/releaseNotes.js +90 -0
- package/dist/content/releaseNotes.js.map +1 -0
- package/dist/main.js +23 -10
- package/dist/main.js.map +1 -1
- package/dist/prompt/instructions/commit.md +18 -15
- package/dist/prompt/instructions/release.md +6 -5
- package/dist/prompt/instructions/review.md +108 -0
- package/dist/prompt/personas/reviewer.md +29 -0
- package/dist/prompt/prompts.js +110 -13
- package/dist/prompt/prompts.js.map +1 -1
- package/dist/types.js +36 -1
- package/dist/types.js.map +1 -1
- package/dist/util/general.js +35 -2
- package/dist/util/general.js.map +1 -1
- package/dist/util/github.js +54 -1
- package/dist/util/github.js.map +1 -1
- package/dist/util/openai.js +68 -4
- package/dist/util/openai.js.map +1 -1
- package/dist/util/stdin.js +61 -0
- package/dist/util/stdin.js.map +1 -0
- package/dist/util/storage.js +20 -1
- package/dist/util/storage.js.map +1 -1
- package/docs/public/commands.md +20 -0
- package/output/kodrdriv/250702-0552-release-notes.md +3 -0
- package/package.json +7 -6
- package/pnpm-workspace.yaml +2 -0
- package/vitest.config.ts +4 -4
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import { getLogger } from '../logging.js';
|
|
5
|
+
import { transcribeAudio } from '../util/openai.js';
|
|
6
|
+
import { run } from '../util/child.js';
|
|
7
|
+
import { DEFAULT_OUTPUT_DIRECTORY } from '../constants.js';
|
|
8
|
+
import { getTimestampedAudioFilename, getOutputPath, getTimestampedTranscriptFilename } from '../util/general.js';
|
|
9
|
+
import { create } from '../util/storage.js';
|
|
10
|
+
import { execute as execute$1 } from './review.js';
|
|
11
|
+
|
|
12
|
+
const detectBestAudioDevice = async ()=>{
|
|
13
|
+
try {
|
|
14
|
+
// Get list of audio devices - this command always "fails" but gives us the device list
|
|
15
|
+
try {
|
|
16
|
+
await run('ffmpeg -f avfoundation -list_devices true -i ""');
|
|
17
|
+
} catch (result) {
|
|
18
|
+
// ffmpeg returns error code but we get the device list in stderr
|
|
19
|
+
const output = result.stderr || result.stdout || '';
|
|
20
|
+
// Parse audio devices from output
|
|
21
|
+
const audioDevicesSection = output.split('AVFoundation audio devices:')[1];
|
|
22
|
+
if (!audioDevicesSection) return '1'; // Default fallback
|
|
23
|
+
const deviceLines = audioDevicesSection.split('\n').filter((line)=>line.includes('[') && line.includes(']')).map((line)=>line.trim());
|
|
24
|
+
// Prefer AirPods, then built-in microphone over virtual/external devices
|
|
25
|
+
const preferredDevices = [
|
|
26
|
+
'AirPods',
|
|
27
|
+
'MacBook Pro Microphone',
|
|
28
|
+
'MacBook Air Microphone',
|
|
29
|
+
'Built-in Microphone',
|
|
30
|
+
'Internal Microphone'
|
|
31
|
+
];
|
|
32
|
+
for (const deviceLine of deviceLines){
|
|
33
|
+
for (const preferred of preferredDevices){
|
|
34
|
+
if (deviceLine.toLowerCase().includes(preferred.toLowerCase())) {
|
|
35
|
+
// Extract device index
|
|
36
|
+
const match = deviceLine.match(/\[(\d+)\]/);
|
|
37
|
+
if (match) {
|
|
38
|
+
return match[1];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// If no preferred device found, use device 1 as default (usually better than 0)
|
|
45
|
+
return '1';
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// Fallback to device 1
|
|
48
|
+
return '1';
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const parseAudioDevices = async ()=>{
|
|
52
|
+
try {
|
|
53
|
+
try {
|
|
54
|
+
await run('ffmpeg -f avfoundation -list_devices true -i ""');
|
|
55
|
+
} catch (result) {
|
|
56
|
+
const output = result.stderr || result.stdout || '';
|
|
57
|
+
const audioDevicesSection = output.split('AVFoundation audio devices:')[1];
|
|
58
|
+
if (audioDevicesSection) {
|
|
59
|
+
const deviceLines = audioDevicesSection.split('\n').filter((line)=>line.includes('[') && line.includes(']')).map((line)=>line.trim());
|
|
60
|
+
return deviceLines.map((line)=>{
|
|
61
|
+
const match = line.match(/\[(\d+)\]\s+(.+)/);
|
|
62
|
+
if (match) {
|
|
63
|
+
return {
|
|
64
|
+
index: match[1],
|
|
65
|
+
name: match[2]
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}).filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return [];
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const selectAudioDeviceInteractively = async (runConfig)=>{
|
|
78
|
+
const logger = getLogger();
|
|
79
|
+
logger.info('🎙️ Available audio devices:');
|
|
80
|
+
const devices = await parseAudioDevices();
|
|
81
|
+
if (devices.length === 0) {
|
|
82
|
+
logger.error('❌ No audio devices found. Make sure ffmpeg is installed and audio devices are available.');
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
// Display devices
|
|
86
|
+
devices.forEach((device, i)=>{
|
|
87
|
+
logger.info(` ${i + 1}. [${device.index}] ${device.name}`);
|
|
88
|
+
});
|
|
89
|
+
logger.info('');
|
|
90
|
+
logger.info('📋 Select an audio device by entering its number (1-' + devices.length + '):');
|
|
91
|
+
return new Promise((resolve)=>{
|
|
92
|
+
// Set up keyboard input
|
|
93
|
+
process.stdin.setRawMode(true);
|
|
94
|
+
process.stdin.resume();
|
|
95
|
+
process.stdin.setEncoding('utf8');
|
|
96
|
+
let inputBuffer = '';
|
|
97
|
+
const keyHandler = (key)=>{
|
|
98
|
+
const keyCode = key.charCodeAt(0);
|
|
99
|
+
if (keyCode === 13) {
|
|
100
|
+
const selectedIndex = parseInt(inputBuffer) - 1;
|
|
101
|
+
if (selectedIndex >= 0 && selectedIndex < devices.length) {
|
|
102
|
+
const selectedDevice = devices[selectedIndex];
|
|
103
|
+
logger.info(`✅ Selected: [${selectedDevice.index}] ${selectedDevice.name}`);
|
|
104
|
+
// Save to configuration
|
|
105
|
+
saveAudioDeviceToConfig(runConfig, selectedDevice.index, selectedDevice.name).then(()=>{
|
|
106
|
+
logger.info('💾 Audio device saved to configuration');
|
|
107
|
+
}).catch((error)=>{
|
|
108
|
+
logger.warn('⚠️ Failed to save audio device to configuration: %s', error.message);
|
|
109
|
+
});
|
|
110
|
+
// Cleanup and resolve
|
|
111
|
+
process.stdin.setRawMode(false);
|
|
112
|
+
process.stdin.pause();
|
|
113
|
+
process.stdin.removeListener('data', keyHandler);
|
|
114
|
+
resolve(selectedDevice.index);
|
|
115
|
+
} else {
|
|
116
|
+
logger.error('❌ Invalid selection. Please enter a number between 1 and ' + devices.length);
|
|
117
|
+
inputBuffer = '';
|
|
118
|
+
process.stdout.write('📋 Select an audio device: ');
|
|
119
|
+
}
|
|
120
|
+
} else if (keyCode === 3) {
|
|
121
|
+
logger.info('\n❌ Selection cancelled');
|
|
122
|
+
process.stdin.setRawMode(false);
|
|
123
|
+
process.stdin.pause();
|
|
124
|
+
process.stdin.removeListener('data', keyHandler);
|
|
125
|
+
resolve(null);
|
|
126
|
+
} else if (keyCode >= 48 && keyCode <= 57) {
|
|
127
|
+
inputBuffer += key;
|
|
128
|
+
process.stdout.write(key);
|
|
129
|
+
} else if (keyCode === 127) {
|
|
130
|
+
if (inputBuffer.length > 0) {
|
|
131
|
+
inputBuffer = inputBuffer.slice(0, -1);
|
|
132
|
+
process.stdout.write('\b \b');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
process.stdin.on('data', keyHandler);
|
|
137
|
+
process.stdout.write('📋 Select an audio device: ');
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
const saveAudioDeviceToConfig = async (runConfig, deviceIndex, deviceName)=>{
|
|
141
|
+
const logger = getLogger();
|
|
142
|
+
const storage = create({
|
|
143
|
+
log: logger.info
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
const configDir = runConfig.configDirectory || DEFAULT_OUTPUT_DIRECTORY;
|
|
147
|
+
await storage.ensureDirectory(configDir);
|
|
148
|
+
const configPath = getOutputPath(configDir, 'audio-config.json');
|
|
149
|
+
// Read existing config or create new one
|
|
150
|
+
let audioConfig = {};
|
|
151
|
+
try {
|
|
152
|
+
const existingConfig = await storage.readFile(configPath, 'utf-8');
|
|
153
|
+
audioConfig = JSON.parse(existingConfig);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// File doesn't exist or is invalid, start with empty config
|
|
156
|
+
audioConfig = {};
|
|
157
|
+
}
|
|
158
|
+
// Update audio device
|
|
159
|
+
audioConfig.audioDevice = deviceIndex;
|
|
160
|
+
audioConfig.audioDeviceName = deviceName;
|
|
161
|
+
audioConfig.lastUpdated = new Date().toISOString();
|
|
162
|
+
// Save updated config
|
|
163
|
+
await storage.writeFile(configPath, JSON.stringify(audioConfig, null, 2), 'utf-8');
|
|
164
|
+
logger.debug('Saved audio configuration to: %s', configPath);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
logger.error('Failed to save audio configuration: %s', error.message);
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const loadAudioDeviceFromConfig = async (runConfig)=>{
|
|
171
|
+
const logger = getLogger();
|
|
172
|
+
const storage = create({
|
|
173
|
+
log: logger.info
|
|
174
|
+
});
|
|
175
|
+
try {
|
|
176
|
+
const configDir = runConfig.configDirectory || DEFAULT_OUTPUT_DIRECTORY;
|
|
177
|
+
const configPath = getOutputPath(configDir, 'audio-config.json');
|
|
178
|
+
const configContent = await storage.readFile(configPath, 'utf-8');
|
|
179
|
+
const audioConfig = JSON.parse(configContent);
|
|
180
|
+
if (audioConfig.audioDevice) {
|
|
181
|
+
logger.debug('Loaded audio device from config: [%s] %s', audioConfig.audioDevice, audioConfig.audioDeviceName || 'Unknown');
|
|
182
|
+
return audioConfig.audioDevice;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// Config file doesn't exist or is invalid
|
|
187
|
+
logger.debug('No saved audio device configuration found');
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const listAudioDevices = async ()=>{
|
|
192
|
+
const logger = getLogger();
|
|
193
|
+
try {
|
|
194
|
+
try {
|
|
195
|
+
await run('ffmpeg -f avfoundation -list_devices true -i ""');
|
|
196
|
+
} catch (result) {
|
|
197
|
+
const output = result.stderr || result.stdout || '';
|
|
198
|
+
const audioDevicesSection = output.split('AVFoundation audio devices:')[1];
|
|
199
|
+
if (audioDevicesSection) {
|
|
200
|
+
logger.info('🎙️ Available audio devices:');
|
|
201
|
+
const deviceLines = audioDevicesSection.split('\n').filter((line)=>line.includes('[') && line.includes(']')).map((line)=>line.trim());
|
|
202
|
+
deviceLines.forEach((line)=>{
|
|
203
|
+
const match = line.match(/\[(\d+)\]\s+(.+)/);
|
|
204
|
+
if (match) {
|
|
205
|
+
logger.info(` [${match[1]}] ${match[2]}`);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
logger.debug('Could not list audio devices');
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const execute = async (runConfig)=>{
|
|
215
|
+
var _runConfig_audioReview, _runConfig_audioReview1, _runConfig_audioReview2, _runConfig_audioReview3, _runConfig_audioReview4, _runConfig_audioReview5, _runConfig_audioReview6, _runConfig_audioReview7, _runConfig_audioReview8, _runConfig_audioReview9, _runConfig_audioReview10, _runConfig_review;
|
|
216
|
+
const logger = getLogger();
|
|
217
|
+
const isDryRun = runConfig.dryRun || false;
|
|
218
|
+
// Handle audio device selection if requested
|
|
219
|
+
if ((_runConfig_audioReview = runConfig.audioReview) === null || _runConfig_audioReview === void 0 ? void 0 : _runConfig_audioReview.selectAudioDevice) {
|
|
220
|
+
logger.info('🎛️ Starting audio device selection...');
|
|
221
|
+
const selectedDevice = await selectAudioDeviceInteractively(runConfig);
|
|
222
|
+
if (selectedDevice === null) {
|
|
223
|
+
logger.error('❌ Audio device selection cancelled or failed');
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
logger.info('✅ Audio device selection complete');
|
|
227
|
+
logger.info('');
|
|
228
|
+
logger.info('You can now run the audio-review command without --select-audio-device to use your saved device');
|
|
229
|
+
return 'Audio device configured successfully';
|
|
230
|
+
}
|
|
231
|
+
if (isDryRun) {
|
|
232
|
+
var _runConfig_review1;
|
|
233
|
+
logger.info('DRY RUN: Would start audio recording for review context');
|
|
234
|
+
logger.info('DRY RUN: Would transcribe audio and use as context for review analysis');
|
|
235
|
+
logger.info('DRY RUN: Would then delegate to regular review command');
|
|
236
|
+
// In dry run, just call the regular review command with empty note
|
|
237
|
+
return execute$1({
|
|
238
|
+
...runConfig,
|
|
239
|
+
review: {
|
|
240
|
+
...runConfig.review,
|
|
241
|
+
note: ((_runConfig_review1 = runConfig.review) === null || _runConfig_review1 === void 0 ? void 0 : _runConfig_review1.note) || ''
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// Start audio recording and transcription
|
|
246
|
+
logger.info('Starting audio recording for review context...');
|
|
247
|
+
logger.info('This command will use your system\'s default audio recording tool');
|
|
248
|
+
logger.info('💡 Tip: Use --select-audio-device to choose a specific microphone');
|
|
249
|
+
logger.info('Press Ctrl+C after you finish speaking to generate your review analysis');
|
|
250
|
+
const audioContext = await recordAndTranscribeAudio(runConfig);
|
|
251
|
+
// Now delegate to the regular review command with the audio context
|
|
252
|
+
logger.info('🤖 Analyzing review using audio context...');
|
|
253
|
+
const result = await execute$1({
|
|
254
|
+
...runConfig,
|
|
255
|
+
review: {
|
|
256
|
+
// Map audioReview configuration to review configuration
|
|
257
|
+
includeCommitHistory: (_runConfig_audioReview1 = runConfig.audioReview) === null || _runConfig_audioReview1 === void 0 ? void 0 : _runConfig_audioReview1.includeCommitHistory,
|
|
258
|
+
includeRecentDiffs: (_runConfig_audioReview2 = runConfig.audioReview) === null || _runConfig_audioReview2 === void 0 ? void 0 : _runConfig_audioReview2.includeRecentDiffs,
|
|
259
|
+
includeReleaseNotes: (_runConfig_audioReview3 = runConfig.audioReview) === null || _runConfig_audioReview3 === void 0 ? void 0 : _runConfig_audioReview3.includeReleaseNotes,
|
|
260
|
+
includeGithubIssues: (_runConfig_audioReview4 = runConfig.audioReview) === null || _runConfig_audioReview4 === void 0 ? void 0 : _runConfig_audioReview4.includeGithubIssues,
|
|
261
|
+
commitHistoryLimit: (_runConfig_audioReview5 = runConfig.audioReview) === null || _runConfig_audioReview5 === void 0 ? void 0 : _runConfig_audioReview5.commitHistoryLimit,
|
|
262
|
+
diffHistoryLimit: (_runConfig_audioReview6 = runConfig.audioReview) === null || _runConfig_audioReview6 === void 0 ? void 0 : _runConfig_audioReview6.diffHistoryLimit,
|
|
263
|
+
releaseNotesLimit: (_runConfig_audioReview7 = runConfig.audioReview) === null || _runConfig_audioReview7 === void 0 ? void 0 : _runConfig_audioReview7.releaseNotesLimit,
|
|
264
|
+
githubIssuesLimit: (_runConfig_audioReview8 = runConfig.audioReview) === null || _runConfig_audioReview8 === void 0 ? void 0 : _runConfig_audioReview8.githubIssuesLimit,
|
|
265
|
+
sendit: (_runConfig_audioReview9 = runConfig.audioReview) === null || _runConfig_audioReview9 === void 0 ? void 0 : _runConfig_audioReview9.sendit,
|
|
266
|
+
context: (_runConfig_audioReview10 = runConfig.audioReview) === null || _runConfig_audioReview10 === void 0 ? void 0 : _runConfig_audioReview10.context,
|
|
267
|
+
// Use the transcribed audio as content
|
|
268
|
+
note: audioContext.trim() || ((_runConfig_review = runConfig.review) === null || _runConfig_review === void 0 ? void 0 : _runConfig_review.note) || ''
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
// Final cleanup to ensure process can exit
|
|
272
|
+
try {
|
|
273
|
+
if (process.stdin.setRawMode) {
|
|
274
|
+
process.stdin.setRawMode(false);
|
|
275
|
+
process.stdin.pause();
|
|
276
|
+
process.stdin.removeAllListeners();
|
|
277
|
+
}
|
|
278
|
+
process.removeAllListeners('SIGINT');
|
|
279
|
+
process.removeAllListeners('SIGTERM');
|
|
280
|
+
} catch (error) {
|
|
281
|
+
// Ignore cleanup errors
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
};
|
|
285
|
+
const recordAndTranscribeAudio = async (runConfig)=>{
|
|
286
|
+
var _runConfig_audioReview;
|
|
287
|
+
const logger = getLogger();
|
|
288
|
+
const outputDirectory = runConfig.outputDirectory || DEFAULT_OUTPUT_DIRECTORY;
|
|
289
|
+
const storage = create({
|
|
290
|
+
log: logger.info
|
|
291
|
+
});
|
|
292
|
+
await storage.ensureDirectory(outputDirectory);
|
|
293
|
+
const tempDir = await fs.mkdtemp(path.join(outputDirectory, '.temp-audio-'));
|
|
294
|
+
const audioFilePath = path.join(tempDir, 'recording.wav');
|
|
295
|
+
// Declare variables at function scope for cleanup access
|
|
296
|
+
let recordingProcess = null;
|
|
297
|
+
let recordingFinished = false;
|
|
298
|
+
let recordingCancelled = false;
|
|
299
|
+
let countdownInterval = null;
|
|
300
|
+
let remainingSeconds = 30;
|
|
301
|
+
let intendedRecordingTime = 30;
|
|
302
|
+
const maxRecordingTime = ((_runConfig_audioReview = runConfig.audioReview) === null || _runConfig_audioReview === void 0 ? void 0 : _runConfig_audioReview.maxRecordingTime) || 300; // 5 minutes default
|
|
303
|
+
const extensionTime = 30; // 30 seconds per extension
|
|
304
|
+
try {
|
|
305
|
+
// Use system recording tool - cross-platform approach
|
|
306
|
+
logger.info('🎤 Starting recording... Speak now!');
|
|
307
|
+
logger.info('📋 Controls: ENTER=done, E=extend+30s, C/Ctrl+C=cancel');
|
|
308
|
+
// List available audio devices in debug mode
|
|
309
|
+
if (runConfig.debug) {
|
|
310
|
+
await listAudioDevices();
|
|
311
|
+
}
|
|
312
|
+
// Start countdown display
|
|
313
|
+
const startCountdown = ()=>{
|
|
314
|
+
// Show initial countdown
|
|
315
|
+
updateCountdownDisplay();
|
|
316
|
+
countdownInterval = setInterval(()=>{
|
|
317
|
+
remainingSeconds--;
|
|
318
|
+
if (remainingSeconds > 0) {
|
|
319
|
+
updateCountdownDisplay();
|
|
320
|
+
} else {
|
|
321
|
+
process.stdout.write('\r⏱️ Recording: Time\'s up! \n');
|
|
322
|
+
if (countdownInterval) {
|
|
323
|
+
clearInterval(countdownInterval);
|
|
324
|
+
countdownInterval = null;
|
|
325
|
+
}
|
|
326
|
+
// Auto-stop when intended time is reached
|
|
327
|
+
stopRecording();
|
|
328
|
+
}
|
|
329
|
+
}, 1000);
|
|
330
|
+
};
|
|
331
|
+
const updateCountdownDisplay = ()=>{
|
|
332
|
+
const maxMinutes = Math.floor(maxRecordingTime / 60);
|
|
333
|
+
const intendedMinutes = Math.floor(intendedRecordingTime / 60);
|
|
334
|
+
const intendedSeconds = intendedRecordingTime % 60;
|
|
335
|
+
process.stdout.write(`\r⏱️ Recording: ${remainingSeconds}s left (${intendedMinutes}:${intendedSeconds.toString().padStart(2, '0')}/${maxMinutes}:00 max) [ENTER=done, E=+30s, C=cancel]`);
|
|
336
|
+
};
|
|
337
|
+
const extendRecording = ()=>{
|
|
338
|
+
const newTotal = intendedRecordingTime + extensionTime;
|
|
339
|
+
if (newTotal <= maxRecordingTime) {
|
|
340
|
+
intendedRecordingTime = newTotal;
|
|
341
|
+
remainingSeconds += extensionTime;
|
|
342
|
+
logger.info(`🔄 Extended recording by ${extensionTime}s (total: ${Math.floor(intendedRecordingTime / 60)}:${(intendedRecordingTime % 60).toString().padStart(2, '0')})`);
|
|
343
|
+
updateCountdownDisplay();
|
|
344
|
+
} else {
|
|
345
|
+
const canExtend = maxRecordingTime - intendedRecordingTime;
|
|
346
|
+
if (canExtend > 0) {
|
|
347
|
+
intendedRecordingTime = maxRecordingTime;
|
|
348
|
+
remainingSeconds += canExtend;
|
|
349
|
+
logger.info(`🔄 Extended recording by ${canExtend}s (maximum reached: ${Math.floor(maxRecordingTime / 60)}:${(maxRecordingTime % 60).toString().padStart(2, '0')})`);
|
|
350
|
+
updateCountdownDisplay();
|
|
351
|
+
} else {
|
|
352
|
+
logger.warn(`⚠️ Cannot extend: maximum recording time (${Math.floor(maxRecordingTime / 60)}:${(maxRecordingTime % 60).toString().padStart(2, '0')}) reached`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
// Set up keyboard input handling
|
|
357
|
+
const setupKeyboardHandling = ()=>{
|
|
358
|
+
process.stdin.setRawMode(true);
|
|
359
|
+
process.stdin.resume();
|
|
360
|
+
process.stdin.setEncoding('utf8');
|
|
361
|
+
const keyHandler = (key)=>{
|
|
362
|
+
const keyCode = key.charCodeAt(0);
|
|
363
|
+
if (keyCode === 13) {
|
|
364
|
+
// Immediate feedback
|
|
365
|
+
process.stdout.write('\r✅ ENTER pressed - stopping recording... \n');
|
|
366
|
+
process.stdin.setRawMode(false);
|
|
367
|
+
process.stdin.pause();
|
|
368
|
+
process.stdin.removeListener('data', keyHandler);
|
|
369
|
+
stopRecording();
|
|
370
|
+
} else if (key.toLowerCase() === 'e') {
|
|
371
|
+
extendRecording();
|
|
372
|
+
} else if (key.toLowerCase() === 'c' || keyCode === 3) {
|
|
373
|
+
// Immediate feedback
|
|
374
|
+
process.stdout.write('\r❌ Cancelling recording... \n');
|
|
375
|
+
process.stdin.setRawMode(false);
|
|
376
|
+
process.stdin.pause();
|
|
377
|
+
process.stdin.removeListener('data', keyHandler);
|
|
378
|
+
cancelRecording();
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
process.stdin.on('data', keyHandler);
|
|
382
|
+
};
|
|
383
|
+
// Determine which recording command to use based on platform (using max time)
|
|
384
|
+
let recordCommand;
|
|
385
|
+
if (process.platform === 'darwin') {
|
|
386
|
+
// macOS - try ffmpeg first, then fall back to manual recording
|
|
387
|
+
try {
|
|
388
|
+
var _runConfig_audioReview1, _runConfig_audioReview2;
|
|
389
|
+
// Check if ffmpeg is available
|
|
390
|
+
await run('which ffmpeg');
|
|
391
|
+
// Get the best audio device (from saved config, CLI config, or auto-detected)
|
|
392
|
+
const savedDevice = await loadAudioDeviceFromConfig(runConfig);
|
|
393
|
+
const audioDevice = ((_runConfig_audioReview1 = runConfig.audioReview) === null || _runConfig_audioReview1 === void 0 ? void 0 : _runConfig_audioReview1.audioDevice) || savedDevice || await detectBestAudioDevice();
|
|
394
|
+
recordCommand = `ffmpeg -f avfoundation -i ":${audioDevice}" -t ${maxRecordingTime} -y "${audioFilePath}"`;
|
|
395
|
+
if ((_runConfig_audioReview2 = runConfig.audioReview) === null || _runConfig_audioReview2 === void 0 ? void 0 : _runConfig_audioReview2.audioDevice) {
|
|
396
|
+
logger.info(`🎙️ Using audio device ${audioDevice} (from CLI configuration)`);
|
|
397
|
+
} else if (savedDevice) {
|
|
398
|
+
logger.info(`🎙️ Using audio device ${audioDevice} (from saved configuration)`);
|
|
399
|
+
} else {
|
|
400
|
+
logger.info(`🎙️ Using audio device ${audioDevice} (auto-detected)`);
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
// ffmpeg not available, try sox/rec
|
|
404
|
+
try {
|
|
405
|
+
await run('which rec');
|
|
406
|
+
recordCommand = `rec -r 44100 -c 1 -t wav "${audioFilePath}" trim 0 ${maxRecordingTime}`;
|
|
407
|
+
} catch {
|
|
408
|
+
// Neither available, use manual fallback
|
|
409
|
+
throw new Error('MANUAL_RECORDING_NEEDED');
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
} else if (process.platform === 'win32') {
|
|
413
|
+
// Windows - use ffmpeg if available, otherwise fallback
|
|
414
|
+
try {
|
|
415
|
+
await run('where ffmpeg');
|
|
416
|
+
recordCommand = `ffmpeg -f dshow -i audio="Microphone" -t ${maxRecordingTime} -y "${audioFilePath}"`;
|
|
417
|
+
} catch {
|
|
418
|
+
throw new Error('MANUAL_RECORDING_NEEDED');
|
|
419
|
+
}
|
|
420
|
+
} else {
|
|
421
|
+
// Linux - use arecord (ALSA) or ffmpeg
|
|
422
|
+
try {
|
|
423
|
+
await run('which arecord');
|
|
424
|
+
recordCommand = `arecord -f cd -t wav -d ${maxRecordingTime} "${audioFilePath}"`;
|
|
425
|
+
} catch {
|
|
426
|
+
try {
|
|
427
|
+
await run('which ffmpeg');
|
|
428
|
+
recordCommand = `ffmpeg -f alsa -i default -t ${maxRecordingTime} -y "${audioFilePath}"`;
|
|
429
|
+
} catch {
|
|
430
|
+
throw new Error('MANUAL_RECORDING_NEEDED');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Start recording as a background process (with max time, we'll stop it early if needed)
|
|
435
|
+
try {
|
|
436
|
+
recordingProcess = run(recordCommand);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (error.message === 'MANUAL_RECORDING_NEEDED') {
|
|
439
|
+
// Provide helpful instructions for manual recording
|
|
440
|
+
logger.warn('⚠️ Automatic recording not available on this system.');
|
|
441
|
+
logger.warn('📱 Please record audio manually using your system\'s built-in tools:');
|
|
442
|
+
logger.warn('');
|
|
443
|
+
if (process.platform === 'darwin') {
|
|
444
|
+
logger.warn('🍎 macOS options:');
|
|
445
|
+
logger.warn(' 1. Use QuickTime Player: File → New Audio Recording');
|
|
446
|
+
logger.warn(' 2. Use Voice Memos app');
|
|
447
|
+
logger.warn(' 3. Install ffmpeg: brew install ffmpeg');
|
|
448
|
+
logger.warn(' 4. Install sox: brew install sox');
|
|
449
|
+
} else if (process.platform === 'win32') {
|
|
450
|
+
logger.warn('🪟 Windows options:');
|
|
451
|
+
logger.warn(' 1. Use Voice Recorder app');
|
|
452
|
+
logger.warn(' 2. Install ffmpeg: https://ffmpeg.org/download.html');
|
|
453
|
+
} else {
|
|
454
|
+
logger.warn('🐧 Linux options:');
|
|
455
|
+
logger.warn(' 1. Install alsa-utils: sudo apt install alsa-utils');
|
|
456
|
+
logger.warn(' 2. Install ffmpeg: sudo apt install ffmpeg');
|
|
457
|
+
}
|
|
458
|
+
logger.warn('');
|
|
459
|
+
logger.warn(`💾 Save your recording as: ${audioFilePath}`);
|
|
460
|
+
logger.warn('🎵 Recommended format: WAV, 44.1kHz, mono or stereo');
|
|
461
|
+
logger.warn('');
|
|
462
|
+
logger.warn('⌨️ Press ENTER when you have saved the audio file...');
|
|
463
|
+
// Wait for user input (disable our keyboard handling for this)
|
|
464
|
+
await new Promise((resolve)=>{
|
|
465
|
+
const originalRawMode = process.stdin.setRawMode;
|
|
466
|
+
process.stdin.setRawMode(true);
|
|
467
|
+
process.stdin.resume();
|
|
468
|
+
const enterHandler = (key)=>{
|
|
469
|
+
if (key[0] === 13) {
|
|
470
|
+
process.stdin.setRawMode(false);
|
|
471
|
+
process.stdin.pause();
|
|
472
|
+
process.stdin.removeListener('data', enterHandler);
|
|
473
|
+
resolve(void 0);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
process.stdin.on('data', enterHandler);
|
|
477
|
+
});
|
|
478
|
+
// Skip the automatic recording and keyboard handling for manual recording
|
|
479
|
+
recordingProcess = null;
|
|
480
|
+
} else {
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// Set up graceful shutdown
|
|
485
|
+
const stopRecording = async ()=>{
|
|
486
|
+
if (!recordingFinished && !recordingCancelled) {
|
|
487
|
+
recordingFinished = true;
|
|
488
|
+
// Clear countdown
|
|
489
|
+
if (countdownInterval) {
|
|
490
|
+
clearInterval(countdownInterval);
|
|
491
|
+
countdownInterval = null;
|
|
492
|
+
}
|
|
493
|
+
// Clear the countdown line and show recording stopped
|
|
494
|
+
process.stdout.write('\r⏱️ Recording finished! \n');
|
|
495
|
+
if (recordingProcess && recordingProcess.kill) {
|
|
496
|
+
recordingProcess.kill('SIGTERM');
|
|
497
|
+
}
|
|
498
|
+
logger.info('🛑 Recording stopped - proceeding with review');
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
// Set up cancellation
|
|
502
|
+
const cancelRecording = async ()=>{
|
|
503
|
+
if (!recordingFinished && !recordingCancelled) {
|
|
504
|
+
recordingCancelled = true;
|
|
505
|
+
// Clear countdown
|
|
506
|
+
if (countdownInterval) {
|
|
507
|
+
clearInterval(countdownInterval);
|
|
508
|
+
countdownInterval = null;
|
|
509
|
+
}
|
|
510
|
+
// Clear the countdown line and show cancellation
|
|
511
|
+
process.stdout.write('\r❌ Recording cancelled! \n');
|
|
512
|
+
if (recordingProcess && recordingProcess.kill) {
|
|
513
|
+
recordingProcess.kill('SIGTERM');
|
|
514
|
+
}
|
|
515
|
+
logger.info('❌ Audio review cancelled by user');
|
|
516
|
+
process.exit(0);
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
// Remove the old SIGINT handler and use our new keyboard handling
|
|
520
|
+
// Note: We'll still handle SIGINT for cleanup, but route it through cancelRecording
|
|
521
|
+
process.on('SIGINT', cancelRecording);
|
|
522
|
+
// Start keyboard handling and countdown if we have a recording process
|
|
523
|
+
if (recordingProcess) {
|
|
524
|
+
setupKeyboardHandling();
|
|
525
|
+
startCountdown();
|
|
526
|
+
// Create a promise that resolves when user manually stops recording
|
|
527
|
+
const manualStopPromise = new Promise((resolve)=>{
|
|
528
|
+
const checkInterval = setInterval(()=>{
|
|
529
|
+
if (recordingFinished || recordingCancelled) {
|
|
530
|
+
clearInterval(checkInterval);
|
|
531
|
+
resolve();
|
|
532
|
+
}
|
|
533
|
+
}, 100);
|
|
534
|
+
});
|
|
535
|
+
// Wait for either the recording to finish naturally or manual stop
|
|
536
|
+
try {
|
|
537
|
+
await Promise.race([
|
|
538
|
+
recordingProcess,
|
|
539
|
+
manualStopPromise
|
|
540
|
+
]);
|
|
541
|
+
// If manually stopped, force kill the process if it's still running
|
|
542
|
+
if (recordingFinished && recordingProcess && !recordingProcess.killed) {
|
|
543
|
+
recordingProcess.kill('SIGKILL');
|
|
544
|
+
// Give it a moment to die
|
|
545
|
+
await new Promise((resolve)=>setTimeout(resolve, 200));
|
|
546
|
+
}
|
|
547
|
+
// Only show completion message if not manually finished
|
|
548
|
+
if (!recordingCancelled && !recordingFinished) {
|
|
549
|
+
// Clear countdown on successful completion
|
|
550
|
+
if (countdownInterval) {
|
|
551
|
+
clearInterval(countdownInterval);
|
|
552
|
+
countdownInterval = null;
|
|
553
|
+
}
|
|
554
|
+
process.stdout.write('\r⏱️ Recording completed! \n');
|
|
555
|
+
logger.info('✅ Recording completed automatically');
|
|
556
|
+
}
|
|
557
|
+
} catch (error) {
|
|
558
|
+
// Only handle errors if not cancelled and not manually finished
|
|
559
|
+
if (!recordingCancelled && !recordingFinished) {
|
|
560
|
+
// Clear countdown on error
|
|
561
|
+
if (countdownInterval) {
|
|
562
|
+
clearInterval(countdownInterval);
|
|
563
|
+
countdownInterval = null;
|
|
564
|
+
}
|
|
565
|
+
if (error.signal === 'SIGTERM' || error.signal === 'SIGKILL') {
|
|
566
|
+
// This is expected when we kill the process
|
|
567
|
+
logger.debug('Recording process terminated as expected');
|
|
568
|
+
} else {
|
|
569
|
+
logger.warn('Recording process ended unexpectedly: %s', error.message);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Always clean up keyboard input
|
|
574
|
+
if (process.stdin.setRawMode) {
|
|
575
|
+
process.stdin.setRawMode(false);
|
|
576
|
+
process.stdin.pause();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// If recording was cancelled, exit early
|
|
580
|
+
if (recordingCancelled) {
|
|
581
|
+
return '';
|
|
582
|
+
}
|
|
583
|
+
// Wait a moment for the recording file to be fully written
|
|
584
|
+
if (recordingFinished) {
|
|
585
|
+
await new Promise((resolve)=>setTimeout(resolve, 500));
|
|
586
|
+
}
|
|
587
|
+
// Check if audio file exists
|
|
588
|
+
try {
|
|
589
|
+
await fs.access(audioFilePath);
|
|
590
|
+
const stats = await fs.stat(audioFilePath);
|
|
591
|
+
if (stats.size === 0) {
|
|
592
|
+
throw new Error('Audio file is empty');
|
|
593
|
+
}
|
|
594
|
+
logger.info('✅ Audio file created successfully (%d bytes)', stats.size);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
throw new Error(`Failed to create audio file: ${error.message}`);
|
|
597
|
+
}
|
|
598
|
+
// Transcribe the audio
|
|
599
|
+
logger.info('🎯 Transcribing audio...');
|
|
600
|
+
logger.info('⏳ This may take a few seconds depending on audio length...');
|
|
601
|
+
const transcription = await transcribeAudio(audioFilePath);
|
|
602
|
+
const audioContext = transcription.text;
|
|
603
|
+
logger.info('✅ Audio transcribed successfully');
|
|
604
|
+
logger.debug('Transcription: %s', audioContext);
|
|
605
|
+
// Save audio file and transcript to output directory
|
|
606
|
+
logger.info('💾 Saving audio file and transcript...');
|
|
607
|
+
try {
|
|
608
|
+
const outputDirectory = runConfig.outputDirectory || DEFAULT_OUTPUT_DIRECTORY;
|
|
609
|
+
const storage = create({
|
|
610
|
+
log: logger.info
|
|
611
|
+
});
|
|
612
|
+
await storage.ensureDirectory(outputDirectory);
|
|
613
|
+
// Save audio file copy
|
|
614
|
+
const audioOutputFilename = getTimestampedAudioFilename();
|
|
615
|
+
const audioOutputPath = getOutputPath(outputDirectory, audioOutputFilename);
|
|
616
|
+
await fs.copyFile(audioFilePath, audioOutputPath);
|
|
617
|
+
logger.debug('Saved audio file: %s', audioOutputPath);
|
|
618
|
+
// Save transcript
|
|
619
|
+
if (audioContext.trim()) {
|
|
620
|
+
const transcriptOutputFilename = getTimestampedTranscriptFilename();
|
|
621
|
+
const transcriptOutputPath = getOutputPath(outputDirectory, transcriptOutputFilename);
|
|
622
|
+
const transcriptContent = `# Audio Transcript\n\n**Recorded:** ${new Date().toISOString()}\n\n**Transcript:**\n\n${audioContext}`;
|
|
623
|
+
await storage.writeFile(transcriptOutputPath, transcriptContent, 'utf-8');
|
|
624
|
+
logger.debug('Saved transcript: %s', transcriptOutputPath);
|
|
625
|
+
}
|
|
626
|
+
} catch (error) {
|
|
627
|
+
logger.warn('Failed to save audio/transcript files: %s', error.message);
|
|
628
|
+
}
|
|
629
|
+
if (!audioContext.trim()) {
|
|
630
|
+
logger.warn('No audio content was transcribed. Proceeding without audio context.');
|
|
631
|
+
return '';
|
|
632
|
+
} else {
|
|
633
|
+
logger.info('📝 Using transcribed audio as review note');
|
|
634
|
+
return audioContext;
|
|
635
|
+
}
|
|
636
|
+
} catch (error) {
|
|
637
|
+
logger.error('Audio recording/transcription failed: %s', error.message);
|
|
638
|
+
logger.info('Proceeding with review analysis without audio context...');
|
|
639
|
+
return '';
|
|
640
|
+
} finally{
|
|
641
|
+
// Comprehensive cleanup to ensure program can exit
|
|
642
|
+
try {
|
|
643
|
+
// Clear any remaining countdown interval
|
|
644
|
+
if (countdownInterval) {
|
|
645
|
+
clearInterval(countdownInterval);
|
|
646
|
+
countdownInterval = null;
|
|
647
|
+
}
|
|
648
|
+
// Ensure stdin is properly reset
|
|
649
|
+
if (process.stdin.setRawMode) {
|
|
650
|
+
process.stdin.setRawMode(false);
|
|
651
|
+
process.stdin.pause();
|
|
652
|
+
process.stdin.removeAllListeners('data');
|
|
653
|
+
}
|
|
654
|
+
// Remove process event listeners that we added
|
|
655
|
+
process.removeAllListeners('SIGINT');
|
|
656
|
+
process.removeAllListeners('SIGTERM');
|
|
657
|
+
// Force kill any remaining recording process
|
|
658
|
+
if (recordingProcess && !recordingProcess.killed) {
|
|
659
|
+
try {
|
|
660
|
+
recordingProcess.kill('SIGKILL');
|
|
661
|
+
} catch (killError) {
|
|
662
|
+
// Ignore kill errors
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
// Clean up temporary directory
|
|
666
|
+
await fs.rm(tempDir, {
|
|
667
|
+
recursive: true,
|
|
668
|
+
force: true
|
|
669
|
+
});
|
|
670
|
+
} catch (cleanupError) {
|
|
671
|
+
logger.debug('Cleanup warning: %s', cleanupError.message);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
export { execute };
|
|
677
|
+
//# sourceMappingURL=audio-review.js.map
|