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 @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **System-audio capture** ([#4](https://github.com/himanshu231204/ScreenSight/issues/4)) —
record the system's audio output (loopback) as a WAV:
- New `screen_capture_audio` MCP tool (9 tools total) returning the recording as an
audio content block.
- New `screensight capture-audio [--duration N]` CLI command.
- New optional dependency group `screensight[audio]` (`soundcard`, `numpy`).
- Opt-in and off by default: requires both the master switch on **and**
`SCREENSIGHT_ENABLE_AUDIO=1`. Duration is capped at 30s.
- `audio.wav` is cleaned up on `screensight off`, alongside `frame.jpg`.
- Loopback via `soundcard`: Windows (WASAPI) and Linux (PulseAudio monitor) work out of
the box; macOS/WSL need a virtual output device (BlackHole/SoundFlower).

## [0.1.0] - 2026-08-11

Initial release. ScreenSight generalizes agent screen-awareness beyond a single
Expand Down
31 changes: 31 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ screensight off # Disable capture (deletes frame)
screensight status # Check switch state
screensight capture # Capture primary display
screensight capture --display 1 # Capture specific display
screensight capture-audio # Record 5s of system audio (opt-in)
screensight capture-audio --duration 10 # Record a specific duration
screensight watch --interval 5 # Watch every 5s (default)
screensight watch --interval 3 --max-frames 20
screensight watch-stop # Stop watch daemon
Expand Down Expand Up @@ -54,6 +56,35 @@ screensight capture --display 1 # a specific display index
When `active_window_title` is `null`, the backend could not determine the foreground
window. Callers must treat that as *not verified safe*, never as *safe*.

## `screensight capture-audio`

Records the system's audio output (loopback — what's playing through the speakers) and
prints a JSON object. **Opt-in:** the master switch must be on **and** the
`SCREENSIGHT_ENABLE_AUDIO=1` environment variable must be set, otherwise the command exits
with code `3`. Requires the audio extras: `pip install 'screensight[audio]'`.

```bash
export SCREENSIGHT_ENABLE_AUDIO=1
screensight capture-audio # 5s (default)
screensight capture-audio --duration 10 # 1–30s
```

```json
{
"path": "C:\\Users\\you\\.screensight\\audio.wav",
"duration": 5.0,
"sample_rate": 44100
}
```

| Flag | Default | Meaning |
|---|---|---|
| `--duration` | `5` | Seconds of audio to record (clamped to 1–30) |

Loopback support: Windows (WASAPI) and Linux (PulseAudio/PipeWire monitor) work out of the
box. macOS and WSL need a virtual output device (BlackHole or SoundFlower) installed and set
as the default output. The `audio.wav` file is deleted on `screensight off`.

## `screensight displays`

```bash
Expand Down
36 changes: 35 additions & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# Configuration

Everything ScreenSight stores lives under `~/.screensight/`. There is no global config
file elsewhere, no environment-variable layer, and no remote state.
file elsewhere and no remote state. The only environment variable is the opt-in audio gate
described in [Audio capture](#audio-capture) below.

```text
~/.screensight/
state.json # Master on/off switch
frame.jpg # Latest captured frame
audio.wav # Latest captured system audio (if audio is used)
daemon.json # Watch daemon status
daemon.pid # Watch daemon process ID
redact_zones.json # Blocklist + redaction zones
Expand All @@ -17,6 +19,7 @@ file elsewhere, no environment-variable layer, and no remote state.
|---|---|---|---|
| `state.json` | `state.py` | `core.py` | Master on/off switch |
| `frame.jpg` | `core.py` via `privacy.process_frame` | CLI, MCP tools | The one current screenshot, overwritten each capture |
| `audio.wav` | `core.py` via `capture.audio` | CLI, MCP tools | The one current audio recording, overwritten each capture |
| `redact_zones.json` | You (or your agent) | `privacy.py` | Blocklist terms + redaction rectangles |
| `daemon.json` | `watch.py` | CLI `watch-status`, MCP `screen_watch_latest` | Running state, frame count, last change |
| `daemon.pid` | `watch.py` on start | `watch.py` on stop | Lets `watch-stop` find the process |
Expand Down Expand Up @@ -93,6 +96,37 @@ screensight on # enable
screensight off # disable + delete frame.jpg
```

## Audio capture

System-audio capture is **opt-in and off by default**. Two independent gates must both be
satisfied before any audio is recorded:

1. The master switch is on (`screensight on`).
2. The `SCREENSIGHT_ENABLE_AUDIO` environment variable is set to `1` (or `true`/`yes`).

```bash
pip install 'screensight[audio]' # soundcard + numpy
export SCREENSIGHT_ENABLE_AUDIO=1
screensight capture-audio --duration 5
```

| Setting | Default | Meaning |
|---|---|---|
| `SCREENSIGHT_ENABLE_AUDIO` | unset (off) | Must be `1`/`true`/`yes` to allow audio capture |
| duration | `5` | Seconds per capture, clamped to `1`–`30` |
| sample rate | `44100` Hz | Fixed |

The recording is written to `~/.screensight/audio.wav` (a single reused file, like
`frame.jpg`) and deleted on `screensight off`.

**Loopback device support** — capture records what's playing through the default speaker:

| Platform | Status |
|---|---|
| Windows | ✅ WASAPI loopback, works out of the box |
| Linux | ✅ PulseAudio/PipeWire monitor source, works out of the box |
| macOS / WSL | ⚠️ requires a virtual output device (BlackHole or SoundFlower) set as the default output |

## Resetting

```bash
Expand Down
33 changes: 30 additions & 3 deletions docs/reference/mcp-tools.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
# MCP tools reference

The MCP server (`screensight-mcp`) exposes **eight tools** over stdio. Unlike the CLI, the
capture tool returns an MCP `Image` content block rather than a file path, so the calling
agent looks at the frame in its own context instead of reading a file off disk.
The MCP server (`screensight-mcp`) exposes **nine tools** over stdio. Unlike the CLI, the
capture tools return MCP `Image`/`Audio` content blocks rather than file paths, so the
calling agent looks at the frame (or listens to the audio) in its own context instead of
reading a file off disk.

| Tool | Description |
|------|-------------|
| [`screen_enable`](#screen_enable) | Turn ON the master switch |
| [`screen_disable`](#screen_disable) | Turn OFF the master switch |
| [`screen_status`](#screen_status) | Check whether capture is enabled |
| [`screen_capture`](#screen_capture) | Capture screen, returns image + window title |
| [`screen_capture_audio`](#screen_capture_audio) | Record system audio, returns audio block |
| [`screen_watch_start`](#screen_watch_start) | Start the bounded watch daemon |
| [`screen_watch_stop`](#screen_watch_stop) | Stop the watch daemon |
| [`screen_watch_latest`](#screen_watch_latest) | Daemon status and frame count |
Expand Down Expand Up @@ -63,6 +65,31 @@ describe what it sees.
blocklist itself. No tool description, prompt or injected instruction can talk the
server into capturing while the switch is off.

## screen_capture_audio

Record the system's audio output (loopback) and return it to the agent.

```text
Input:
- duration (default 5): seconds to record (clamped to 1–30)
- question (optional): text echoed back for context
Output: Audio content block (WAV) + text with duration, sample rate and path
```

**Opt-in and off by default.** Beyond the master switch, audio requires the
`SCREENSIGHT_ENABLE_AUDIO=1` environment variable, and the server must be installed with the
audio extras (`pip install 'screensight[audio]'`). If either is missing, the tool returns a
clear `Audio capture failed: ...` message rather than recording.

Loopback works out of the box on Windows (WASAPI) and Linux (PulseAudio/PipeWire monitor);
macOS and WSL need a virtual output device (BlackHole/SoundFlower). The `audio.wav` file is
deleted on `screen_disable`.

!!! info "The switch is enforced below this tool"
Like `screen_capture`, this tool calls into `core.capture_audio()`, which re-checks the
master switch and the `SCREENSIGHT_ENABLE_AUDIO` gate itself — no prompt can talk the
server into recording while either is off.

## screen_watch_start

Start a detached background daemon that captures on an interval.
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ dependencies = [
]

[project.optional-dependencies]
audio = [
"soundcard>=0.4.3",
"numpy>=1.24",
]
dev = [
"pytest>=8.0",
"ruff>=0.16,<0.17",
Expand Down
27 changes: 27 additions & 0 deletions src/screensight/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
screensight off
screensight status
screensight capture [--display N]
screensight capture-audio [--duration N]
screensight watch [--interval N] [--max-frames N]
screensight watch-stop
screensight watch-status
Expand All @@ -19,6 +20,7 @@
import sys

from . import core, state, watch
from .config import DEFAULT_AUDIO_DURATION


def cmd_on(args: argparse.Namespace) -> None:
Expand Down Expand Up @@ -51,6 +53,22 @@ def cmd_capture(args: argparse.Namespace) -> None:
)


def cmd_capture_audio(args: argparse.Namespace) -> None:
outcome = core.capture_audio(duration=args.duration)
if not outcome.ok:
print(json.dumps({"error": outcome.error}), file=sys.stderr)
sys.exit(3)
print(
json.dumps(
{
"path": outcome.path,
"duration": outcome.duration,
"sample_rate": outcome.sample_rate,
}
)
)


def cmd_watch(args: argparse.Namespace) -> None:
result = watch.start_daemon(
interval=args.interval,
Expand Down Expand Up @@ -88,6 +106,14 @@ def main() -> None:
cap = sub.add_parser("capture", help="Capture the current screen")
cap.add_argument("--display", type=int, default=None, help="Display index (omit for primary)")

cap_audio = sub.add_parser("capture-audio", help="Record system audio output (loopback)")
cap_audio.add_argument(
"--duration",
type=int,
default=DEFAULT_AUDIO_DURATION,
help="Seconds of audio to record (default: %(default)s, max 30)",
)

w = sub.add_parser("watch", help="Start a bounded watch session")
w.add_argument(
"--interval",
Expand All @@ -113,6 +139,7 @@ def main() -> None:
"off": cmd_off,
"status": cmd_status,
"capture": cmd_capture,
"capture-audio": cmd_capture_audio,
"watch": cmd_watch,
"watch-stop": cmd_watch_stop,
"watch-status": cmd_watch_status,
Expand Down
85 changes: 85 additions & 0 deletions src/screensight/capture/audio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""System-audio (loopback) capture — the audio counterpart to the screenshot
backends. Kept in its own module because audio is time-based (a duration),
not instantaneous like a screenshot, and because its dependencies
(``soundcard``, ``numpy``) are optional extras that the core screenshot path
must never import.

Platform support via ``soundcard``:
- Windows: WASAPI loopback works out of the box (records the default speaker).
- Linux: PulseAudio/PipeWire monitor source works out of the box.
- macOS / WSL: there is no OS loopback device; a virtual one
(BlackHole, SoundFlower) must be installed and selected as the default
output, otherwise no loopback microphone exists and capture fails with an
actionable error.

Every failure path returns ``AudioResult(ok=False, error=...)`` — capture must
degrade gracefully rather than raise, matching the rest of the package.
"""

from __future__ import annotations

import wave
from dataclasses import dataclass

_DEPS_HINT = "audio deps not installed — run: pip install 'screensight[audio]'"
_NO_LOOPBACK_HINT = (
"no loopback audio device found — on macOS/WSL install a virtual output "
"device (BlackHole or SoundFlower) and set it as the default output"
)


@dataclass
class AudioResult:
ok: bool
path: str | None = None
error: str | None = None
duration: float | None = None
sample_rate: int | None = None
channels: int | None = None


def record_system_audio(out_path: str, duration: float, sample_rate: int) -> AudioResult:
"""Record ``duration`` seconds of system audio output to ``out_path`` as a
16-bit PCM WAV. Returns an :class:`AudioResult`; never raises."""
try:
import numpy as np
import soundcard as sc # type: ignore[import-untyped]
except ImportError:
return AudioResult(ok=False, error=_DEPS_HINT)

try:
speaker = sc.default_speaker()
if speaker is None:
return AudioResult(ok=False, error=_NO_LOOPBACK_HINT)

# include_loopback=True returns a microphone that records the speaker's
# output rather than a physical input.
loopback = sc.get_microphone(speaker.name, include_loopback=True)
if loopback is None:
return AudioResult(ok=False, error=_NO_LOOPBACK_HINT)

frames = int(duration * sample_rate)
with loopback.recorder(samplerate=sample_rate) as rec:
data = rec.record(numframes=frames) # float32 array, shape (frames, channels)

channels = data.shape[1] if data.ndim > 1 else 1

# Convert float32 [-1.0, 1.0] to int16 PCM, clipping out-of-range values.
clipped = np.clip(data, -1.0, 1.0)
pcm16 = (clipped * 32767).astype("<i2")

with wave.open(out_path, "wb") as wav:
wav.setnchannels(channels)
wav.setsampwidth(2) # 16-bit
wav.setframerate(sample_rate)
wav.writeframes(pcm16.tobytes())

return AudioResult(
ok=True,
path=out_path,
duration=float(duration),
sample_rate=sample_rate,
channels=channels,
)
except Exception as e: # capture must fail closed, never raise
return AudioResult(ok=False, error=f"audio capture failed: {e}")
14 changes: 14 additions & 0 deletions src/screensight/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
from __future__ import annotations

import json
import os
import platform
from pathlib import Path

HOME = Path.home()
BASE_DIR = HOME / ".screensight"
FRAME_PATH = BASE_DIR / "frame.jpg"
AUDIO_PATH = BASE_DIR / "audio.wav" # system-audio loopback capture (single reused file)
STATE_FILE = BASE_DIR / "state.json" # master on/off switch
DAEMON_STATUS_FILE = BASE_DIR / "daemon.json" # written by the watch daemon
DAEMON_PID_FILE = BASE_DIR / "daemon.pid"
Expand All @@ -20,6 +22,11 @@
MAX_FRAMES_PER_WATCH = 10 # hard cap so a forgotten daemon can't burn context/tokens
AUTO_OFF_ON_WATCH_END = True

# Audio capture (opt-in, off by default — privacy-first, like the master switch)
DEFAULT_AUDIO_DURATION = 5 # seconds per capture
MAX_AUDIO_DURATION = 30 # hard cap so a single call can't record forever / burn tokens
AUDIO_SAMPLE_RATE = 44100 # Hz

# WSL-specific timeouts (PowerShell via WSL is slower than native Windows)
WSL_CAPTURE_TIMEOUT = 30 # seconds — PowerShell capture from WSL
WSL_PATH_CONVERT_TIMEOUT = 5 # seconds — wslpath conversion
Expand All @@ -43,6 +50,13 @@ def ensure_base_dir() -> None:
BASE_DIR.mkdir(parents=True, exist_ok=True)


def audio_enabled() -> bool:
"""Whether system-audio capture is allowed. Off unless the user opts in via
the SCREENSIGHT_ENABLE_AUDIO env var — a separate gate on top of the master
switch, so audio is never recorded by default."""
return os.environ.get("SCREENSIGHT_ENABLE_AUDIO", "").strip().lower() in ("1", "true", "yes")


def get_os() -> str:
system = platform.system().lower()
if system == "linux" and "microsoft" in platform.uname().release.lower():
Expand Down
Loading