Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ project adheres to [Semantic Versioning](https://semver.org/).
- ESLint (flat config, typescript-eslint recommended) with a `lint` script, run
in CI (#23). Tooling only; not part of the published package.

## [0.8.10]

### Fixed

- **Audio device enumeration no longer hangs for 30s with a misleading timeout**
(#40). The dshow `-list_devices` probe can block on some setups (a misbehaving
audio driver), and `list_audio_devices` / system-audio recording surfaced the
raw `ffmpeg timed out after 30s` text. Enumeration now uses a 12s cap, closes
the child's stdin (a known hang cause), and returns a clear "could not
enumerate audio devices" message with a fix hint. A successful result is cached
for the process so an audio recording does not re-pay the probe; the
`list_audio_devices` tool forces a fresh probe so a newly enabled device still
shows up.

## [0.8.9]

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tmhs/screencast-mcp",
"version": "0.8.9",
"version": "0.8.10",
"description": "MCP server for Windows screen recording, frame sampling, and minimal ffmpeg edits",
"type": "module",
"main": "dist/index.js",
Expand Down
4 changes: 3 additions & 1 deletion src/tools/listAudioDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export function register(server: McpServer): void {
async () => {
try {
requireFfmpeg();
const devices = await listDshowAudioDevices();
// Always re-probe: this is the explicit "show me what's available" call,
// so a device enabled since the last recording must show up.
const devices = await listDshowAudioDevices(true);
const loopback = pickLoopbackDevice(devices);
return okResponse({
devices,
Expand Down
55 changes: 47 additions & 8 deletions src/utils/audioDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,56 @@ export function pickLoopbackDevice(
);
}

/**
* Device enumeration must not block a recording start for long. The dshow
* `-list_devices` probe hangs on some setups (a misbehaving audio driver, no
* audio hardware), so it is capped well below the normal ffmpeg job timeout.
*/
export const ENUM_TIMEOUT_MS = 12_000;

const ENUM_FAILED_HINT =
"Enumerating DirectShow audio devices timed out or failed - usually a " +
"misbehaving audio driver, or no audio device present. Record without system " +
"audio, or fix the device, then retry. " + NO_LOOPBACK_HINT;

// A successful enumeration is cached for the process lifetime so an audio
// recording does not re-pay the cost (or risk the hang) on every start. The
// list_audio_devices tool forces a refresh, so a device enabled mid-session is
// still discoverable.
let cachedDevices: string[] | null = null;

/** Run ffmpeg's device list and return the audio device names. ffmpeg exits
* non-zero for the dummy input by design, so the exit code is ignored and the
* names are read from stderr. */
export async function listDshowAudioDevices(): Promise<string[]> {
* names are read from stderr. A timeout or spawn failure becomes a clear,
* actionable error rather than the raw "ffmpeg timed out" text. */
export async function listDshowAudioDevices(forceRefresh = false): Promise<string[]> {
if (!forceRefresh && cachedDevices) return cachedDevices;
const { ffmpeg } = requireFfmpeg();
const res = await runCapture(
ffmpeg,
["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"],
30_000,
);
return parseDshowAudioDevices(res.stderr);
let res;
try {
res = await runCapture(
ffmpeg,
["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"],
ENUM_TIMEOUT_MS,
{ closeStdin: true },
);
} catch (err) {
// runCapture rejects on timeout (and on spawn error); ffmpeg's by-design
// non-zero exit for the dummy input resolves instead, so reaching here is a
// genuine failure, not the expected exit code.
throw new ScreencastError(
`Could not enumerate audio devices (${(err as Error).message}).`,
ENUM_FAILED_HINT,
);
}
const devices = parseDshowAudioDevices(res.stderr);
cachedDevices = devices;
return devices;
}

/** Drop the cached device list (test seam; also lets a caller force a re-probe). */
export function clearAudioDeviceCache(): void {
cachedDevices = null;
}

/** Resolve the loopback device to use, throwing a clear, actionable error when
Expand Down
12 changes: 12 additions & 0 deletions src/utils/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,27 @@ export interface RunResult {
stderr: string;
}

export interface RunCaptureOptions {
/**
* End the child's stdin immediately. dshow device enumeration
* (`-list_devices`) can block waiting on stdin on some setups; closing it
* removes that hang as a cause. Safe only for jobs that do not read stdin
* (which is every short job that goes through runCapture).
*/
closeStdin?: boolean;
}

/** Run a binary to completion, capturing stdout/stderr. Used for ffprobe and
* short ffmpeg edit jobs (not for long-running captures). */
export function runCapture(
bin: string,
args: string[],
timeoutMs = 5 * 60 * 1000,
opts: RunCaptureOptions = {},
): Promise<RunResult> {
return new Promise((resolve, reject) => {
const child = spawn(bin, args, { windowsHide: true });
if (opts.closeStdin) child.stdin?.end();
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
Expand Down
Loading