DEV Community

Ruslan Manov
Ruslan Manov

Posted on

I Turned a Webcam Into an Ambient Light Sensor

Building a Rust-Powered Adaptive Brightness Controller for the Desktop That Mobile Left Behind


The 3 AM Problem

It starts at 11 PM. You're deep in code, the room is dark, your monitor is comfortable. By 3 AM you're still going — the screen hasn't changed, but your eyes ache and you don't know why. By 7 AM, sunlight is flooding the room. Your monitor is still at midnight brightness. The text is washed out. You squint, you lean forward, you finally remember to hit Fn+Up five times.

Now pick up your phone. It handled all of this automatically. Since 2009.

Every phone made in the last 15 years auto-adjusts brightness. The ambient light sensor — a $0.30 chip — detects room light and smoothly adjusts the screen. You never think about it.

Every desktop? Nothing. Unless you have a premium laptop with a dedicated ambient light sensor (Dell XPS, MacBook, ThinkPad X1), your screen brightness is 100% manual. That's most desktops, most monitors, and most budget laptops.

I spend too many nights coding sessions that bleed into mornings. The brightness transition problem wasn't theoretical — it was happening to me every week. So I built a solution.


The Insight: You Already Have a Light Sensor

Every laptop has a webcam. Every desktop has a USB webcam (or can get one for $10). A webcam captures light. If you can measure the average brightness of a webcam frame, you can measure ambient light.

Similarly: every computer has a microphone. If the room is noisy (dishwasher, traffic, music), you probably want higher volume. If it's quiet (3 AM, everyone sleeping), you want lower volume.

No extra hardware. No dedicated sensors. Just the webcam and microphone you already have.


Architecture: Dual Backend, Graceful Degradation

┌──────────────────────────────────────────┐
│     adaptive_brightness_volume.py        │
│           (Main Controller)              │
└──────────────────┬───────────────────────┘
                   │
         ┌─────────┴──────────┐
         │                    │
   ┌─────▼──────┐     ┌──────▼───────┐
   │ Rust SIMD  │     │ Python+Numba │
   │ Engine     │     │ JIT Fallback │
   │ (3-6ms)    │     │ (12.3ms)     │
   └─────┬──────┘     └──────┬───────┘
         │                    │
   ┌─────▼────────────────────▼────────┐
   │        System Layer               │
   │  Camera (OpenCV/V4L2)             │
   │  Audio (cpal/SoundDevice)         │
   │  Brightness (sysfs/DDC-CI)        │
   │  Volume (ALSA/PulseAudio)         │
   └───────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The Python controller auto-detects whether the Rust engine is compiled. If yes, it calls Rust functions via PyO3 with zero-copy NumPy interop. If not, it falls back to Python with Numba JIT compilation (still 10-100x faster than pure Python).

This means you can start using the tool immediately (Python mode) and optionally compile the Rust engine later for maximum performance.


The Performance Journey

Phase 1: Pure Python (~100ms cycles)

The first version processed webcam frames with NumPy and called subprocess for brightness control. It worked, but each cycle took ~100ms — fine for a 30-minute cron job, but noticeable as a real-time daemon.

Phase 2: Numba JIT (12.3ms cycles)

Adding @njit decorators to the hot numerical functions gave a 10x speedup with zero algorithm changes:

@njit(cache=True)
def compute_noise_level(audio_data):
    """RMS noise calculation — Numba compiles to native code"""
    total = 0.0
    for sample in audio_data:
        total += sample * sample
    return np.sqrt(total / len(audio_data))
Enter fullscreen mode Exit fullscreen mode

Startup increased (Numba JIT compilation takes 2-3 seconds on first run), but steady-state performance was solid.

Phase 3: Rust SIMD (3-6ms cycles) — v1.2.0 → v2.0.0

The final evolution: a Rust workspace with 3 crates, spanning 8 tagged releases.

Core crate — 8 specialized modules:

// brightness.rs — 8-wide SIMD vectorization
pub fn calculate_brightness(frame: &[u8]) -> f64 {
    let chunks = frame.chunks_exact(8);
    let remainder = chunks.remainder();
    let mut sum: u64 = chunks.fold(0u64, |acc, chunk| {
        // Compiler auto-vectorizes this to SIMD
        acc + chunk.iter().map(|&b| b as u64).sum::<u64>()
    });
    sum += remainder.iter().map(|&b| b as u64).sum::<u64>();
    sum as f64 / frame.len() as f64
}
Enter fullscreen mode Exit fullscreen mode
// change.rs — branchless significant change detection
pub fn check_significant_change(current: f64, previous: f64, threshold: f64) -> bool {
    (current - previous).abs() > threshold
}
Enter fullscreen mode Exit fullscreen mode

FFI crate — PyO3 zero-copy bindings:

#[pyfunction]
fn compute_noise_level(audio: PyReadonlyArrayDyn<f64>) -> f64 {
    let slice = audio.as_slice().unwrap();
    adaptive_core::audio::compute_noise_level(slice)
}
Enter fullscreen mode Exit fullscreen mode

Binary crate — standalone Rust controller with crossbeam lock-free channels.

The Numbers

Function Python+Numba Rust SIMD Speedup
Audio RMS 0.15ms 0.03ms 5x
Brightness mapping 0.008ms 0.002ms 4x
Volume mapping 0.015ms 0.003ms 5x
Screen analysis 0.05ms 0.01ms 5x
Full cycle 12.3ms 3-6ms 2-4x
Memory 50-80MB 10-20MB 4x
Startup 2-3s <100ms 30x

The Version Timeline — 8 Releases, Each Solving a Real Problem

Tag Milestone What It Fixed
v1.0.0 First stable release Dual-backend architecture ready for daily use
v1.1.0 Security & stability 7 bugs: bare except: catching SystemExit, shell injection, sysfs brightness
v1.2.0 Auto-exit mode Converge in ~23s & stop — no more daemon overhead
v1.2.0-windows Windows Rust port nixctrlc, V4L2→NOAA sun sim, PowerShell WMI, C# Core Audio
v1.2.1 Auto-exit default Convergence approach proved so reliable it became default
v1.2.2 Convergence fix Rust compared smoothed vs current target instead of previous — subtle but critical
v1.3.0 Solar intelligence NOAA seasonal adaptation ported to Rust engine
v2.0.0 Full maturity V4L2 exposure lock, NVIDIA workaround, comprehensive Windows support

The v1.2.0-windows port is particularly notable — it replaced Linux-only system calls (nix for signals, v4l for camera) with cross-platform alternatives (ctrlc, PowerShell WMI brightness, pre-compiled C# helper for Windows Core Audio volume) while keeping the same SIMD core untouched. The architecture's separation of core algorithms from system integration paid off.


The 5 Design Decisions That Made It Work

1. Empirical Brightness Curves (Theory Was Wrong)

I started with a theoretical linear brightness mapping. Wrong — too aggressive at the extremes. Then a logarithmic curve. Wrong — too conservative in the mid-range.

The final solution: a piecewise brightness curve tuned through 3 iterations of daily use over several weeks. The multiplier went from 0.1 (barely moves) to 0.24 (noticeable but conservative) to 0.35 (natural-feeling).

The comfortable range turned out to be 5-45% brightness and 3-35% volume. Human brightness perception is deeply nonlinear and context-dependent — no formula captures it. You have to live with the tool and adjust.

2. Flash Detection Prevents False Activations

Early versions reacted to everything: car headlights through the window, lightning, opening a bright browser tab. The solution: a 40-second environmental sample before committing to adjustment.

The manager script reads brightness/volume, waits 40 seconds, reads again. If the delta is <40%, it exits — the change was transient. This one feature eliminated 90% of false activations and reduced energy usage from constant polling to targeted activation.

3. NOAA Sunrise/Sunset = ~90% Energy Savings

Why run the controller at 2 PM when the sun hasn't moved meaningfully in hours? Or at 2 AM in a stable dark room?

The tool calculates actual sunrise and sunset times for the user's geographic coordinates using NOAA astronomical algorithms. It only activates during transition windows: 30 minutes before sunrise → 2 hours after sunrise, and 30 minutes before sunset → 2 hours after sunset.

# sunrise_sunset_calculator.py — NOAA algorithm
def calculate_sunrise_sunset(lat, lon, date):
    """Pure Python NOAA solar calculations.
    Returns sunrise/sunset times for any location on Earth."""
    julian_day = to_julian(date)
    solar_noon = calculate_solar_noon(julian_day, lon)
    hour_angle = calculate_hour_angle(julian_day, lat)
    sunrise = solar_noon - hour_angle / 360
    sunset = solar_noon + hour_angle / 360
    return sunrise, sunset
Enter fullscreen mode Exit fullscreen mode

Energy savings evolution:

  • v1: Every 5 minutes, always → 0% savings
  • v2: Every 30 minutes with flash detection → 81% savings
  • v3: Only during solar transitions → ~90% savings

4. Auto-Exit Convergence (Not a Daemon)

Most similar tools run as permanent daemons. This tool doesn't. It activates, converges to optimal brightness/volume in ~23 seconds, then exits cleanly. The cron-based manager handles scheduling.

Why? Because a daemon that holds the webcam and microphone open causes:

  • Taskbar microphone icon flickering
  • Camera LED staying on
  • Other apps can't access the camera
  • CPU/memory waste during stable conditions

Auto-exit means: activate → adapt → release everything → stop. Clean, resource-friendly, invisible.

5. Comprehensive Cleanup Eliminates Browser Lag

This was a hard-won lesson. OpenCV + audio capture + Numba JIT cache = significant resource footprint. Without proper cleanup:

  • Chrome would stutter for 10-20 seconds after the script finished
  • Audio devices would stay locked
  • Memory wouldn't be released

The cleanup sequence:

  1. Thread termination with timeout
  2. OpenCV device release (cap.release())
  3. Audio stream close
  4. Numba JIT cache clearing
  5. Multi-pass garbage collection (gc.collect() × 3)

This eliminated the browser lag completely.


Competitive Landscape

Feature This Tool Clight wluma Windows/macOS Built-in
Light detection Webcam Webcam + ALS ALS + Screen Hardware ALS only
Volume adaptation Yes No No No
Performance engine Rust SIMD C Rust OS-native
Rust binary cross-platform Linux + Windows N/A N/A N/A
Platforms Linux + Windows Linux only Wayland only OS-locked
Smart scheduling NOAA sunrise/sunset None None None
External hardware None required None required ALS recommended ALS required
Auto-exit Yes (~23s) No (daemon) No (daemon) N/A
Release cadence 8 releases (v1→v2) Slow Moderate OS-tied
Open source MIT GPL ISC No

The gap this fills: If your machine doesn't have a hardware ambient light sensor (most desktops, budget laptops, external monitors), there is no good cross-platform solution. Clight is Linux-only with no volume support. wluma is Wayland-only and admits webcam detection is unreliable. Windows/macOS require dedicated hardware.

Note: f.lux is not a competitor — it adjusts color temperature (blue light warmth), not brightness levels. They solve different problems. Use both together.


Getting Started

# Clone
git clone https://github.com/RMANOV/Auto-Brightness-Sound-Levels-Windows-Linux.git
cd Auto-Brightness-Sound-Levels-Windows-Linux

# Quick start (Python mode)
python adaptive_brightness_volume.py

# With Rust engine (optional, for maximum performance)
cd adaptive-rust && cargo build --release
cd .. && python adaptive_brightness_volume.py  # Auto-detects Rust

# Automated scheduling
./install_crontab.sh
Enter fullscreen mode Exit fullscreen mode

What I Learned

  1. Live with your tool. Brightness mapping can't be designed theoretically. You need weeks of daily use to get the curve right.
  2. Energy efficiency is a feature, not an afterthought. Going from always-on to sunrise/sunset scheduling changed the tool from "annoying background process" to "invisible helper."
  3. Clean up your resources. In system-level tools, sloppy cleanup = user-visible lag. The multi-stage cleanup sequence was the difference between "Chrome stutters after my script" and "I forgot the script even ran."
  4. Rust SIMD is real. The 2-4x cycle time improvement is nice, but the 4x memory reduction and 30x startup improvement are what made the Rust version feel qualitatively different.
  5. Graceful degradation is worth the complexity. Dual-backend means users can start immediately with Python and upgrade to Rust later. Multiple brightness backends (sysfs, DDC-CI, xrandr) mean it works on more hardware configurations.
  6. Cross-platform Rust is achievable with clean architecture. The v1.2.0-windows port replaced only the system integration layer (nixctrlc, V4L2→NOAA sun simulation, sysfs→PowerShell WMI, ALSA→C# Core Audio) while the SIMD core compiled unchanged. 8 releases in rapid succession — each tagged version solving a real problem from daily use.

GitHub: https://github.com/RMANOV/Auto-Brightness-Sound-Levels-Windows-Linux
License: MIT
Stack: Rust + SIMD | PyO3 | OpenCV | cpal | Numba JIT | NOAA Algorithms | PowerShell WMI | C# Core Audio
Releases: v1.0.0 → v2.0.0 (8 tags) | Dependabot active


Built during too many 11 PM → 7 AM sessions where I forgot to adjust my screen brightness. My eyes say thank you.

Top comments (0)