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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ The decision tree:
bag tracking (`/newbag`, multiple bags, `/tossbag`), smart plug hooks for cold-start `/wake`
(README "Smart plug cold start"), shot-video camera module (README
"Camera module" — MATEBOT_CAMERA=1, needs ffmpeg + HTTPS in front of the
camera page; getUserMedia refuses plain HTTP).
camera page; getUserMedia refuses plain HTTP). Camera shots auto-calibrate
their chart-sync offset from the pump's audio onset (calibrate.py) and get
a rendered clip+chart "shot reel" sent to the chat (render.py,
MATEBOT_REEL=0 to disable; needs matplotlib).

Common failure modes: token/chat id swapped or quoted wrong; bot and machine
on different networks/VLANs; machine powered off at a dumb power strip;
Expand Down
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,17 @@ MATEbot itself serves plain HTTP; put it behind whatever TLS you have:
`matebot.example.com` → `127.0.0.1:8877` (WebSocket support needed)
- or zero-config on a tailnet: `tailscale serve https / http://127.0.0.1:8877`

Sync calibration: detection and stream latency mean the video usually starts
~1 s after the true shot start. `MATEBOT_CAMERA_OFFSET` (default `-1.0`)
corrects this globally; watch one replay and nudge per shot with
`/vsync +0.5` if needed. Videos rotate out after `MATEBOT_VIDEO_KEEP` shots
(git history keeps every clip recoverable).
Sync is calibrated automatically: the pump is loud, so MATEbot aligns its
audio onset in the clip with the pump command in the shot data and writes the
exact per-shot offset. If a clip has no usable audio the fallback is
`MATEBOT_CAMERA_OFFSET` (default `-1.0`), and `/vsync +0.5` still nudges any
shot manually. Videos rotate out after `MATEBOT_VIDEO_KEEP` shots (git
history keeps every clip recoverable).

After each recorded shot MATEbot also renders a **shot reel** — a portrait
video with the clip on top and the chart animating below it, playhead
tracking the x axis — and sends it to the chat (ready for sharing). Disable
with `MATEBOT_REEL=0`; it needs ffmpeg and the `plots` extra (matplotlib).

## Configuration

Expand Down
90 changes: 90 additions & 0 deletions src/matebot/calibrate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Auto-calibrate the video/chart sync offset from the pump's audio onset.

A vibratory pump is loud. The moment it kicks in is visible in the shot data
(``tp`` — target pressure — leaves zero) and audible in the clip, so aligning
the two gives the exact offset with no manual ``/vsync`` needed:

offset = t_pump_in_video - t_pump_in_chart

Pure python on 8 kHz mono PCM — no numpy. Returns ``None`` whenever the
signal is unclear; callers then keep the configured default.
"""

from __future__ import annotations

import asyncio
import logging
import struct
from pathlib import Path

from .slog import parse_slog

log = logging.getLogger(__name__)

SAMPLE_RATE = 8000
WINDOW_S = 0.05
SUSTAIN_WINDOWS = 6 # onset must stay loud for 0.3 s (a clink won't)


async def audio_onset(clip: Path) -> float | None:
"""Time (s) at which the clip's audio gets loud and stays loud."""
try:
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(clip),
"-vn", "-ac", "1", "-ar", str(SAMPLE_RATE), "-f", "s16le", "pipe:1",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
)
except FileNotFoundError:
return None
pcm, _ = await proc.communicate()
if proc.returncode != 0 or len(pcm) < SAMPLE_RATE: # needs ≥0.5 s of audio
return None
n = len(pcm) // 2
samples = struct.unpack(f"<{n}h", pcm[: n * 2])
win = int(WINDOW_S * SAMPLE_RATE)
rms = [
(sum(x * x for x in samples[i : i + win]) / win) ** 0.5
for i in range(0, n - win, win)
]
if len(rms) < 30:
return None
head = sorted(rms[:20])
base = head[len(head) // 2] # median of the first second
peak = max(rms)
if peak < max(base, 1.0) * 4: # no clear loud event (or mic muted)
return None
thresh = max(base * 5, peak * 0.25)
for i in range(len(rms) - SUSTAIN_WINDOWS):
if all(r > thresh for r in rms[i : i + SUSTAIN_WINDOWS]):
return round(i * WINDOW_S, 2)
return None


def pump_start(slog_bytes: bytes) -> float | None:
"""Time (s) at which the shot data first commands the pump."""
shot = parse_slog(slog_bytes)
step = shot.sample_interval_ms / 1000
for key, thresh in (("tp", 0.1), ("fl", 0.3)):
for i, v in enumerate(shot.series.get(key, [])):
if v > thresh:
return round(i * step, 2)
return None


async def calibrate_offset(repo: str | Path, shot_id: int) -> float | None:
"""Offset for the shot's sidecar (video t = chart t + offset), or None."""
repo = Path(repo)
sid = f"{shot_id:06d}"
clip = repo / "shots" / f"{sid}.mp4"
slog = repo / "shots" / f"{sid}.slog"
if not clip.exists() or not slog.exists():
return None
t_video = await audio_onset(clip)
t_chart = pump_start(slog.read_bytes())
if t_video is None or t_chart is None:
return None
offset = round(t_video - t_chart, 2)
if not -5.0 <= offset <= 5.0: # implausible match, don't trust it
log.info("calibration rejected (offset %+.2fs out of range)", offset)
return None
return offset
31 changes: 30 additions & 1 deletion src/matebot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,42 @@ async def try_attach_clip(shot_id: int | None = None) -> None:
await attach_video(config.data_repo, sid, clip,
offset=config.camera_offset)
log.info("camera clip attached to shot %06d", sid)
schedule_sync(quiet=True)
asyncio.create_task(post_video(sid))
except VideoError as exc:
log.warning("clip attach failed: %s", exc)
await messenger.send(f"🎬 Couldn't process the shot video: {exc}")
finally:
pathlib.Path(clip).unlink(missing_ok=True)

async def post_video(sid: int) -> None:
"""After a clip attaches: auto-calibrate its sync offset from the
pump's audio onset, then render + send the shot reel."""
try:
from .calibrate import calibrate_offset
from .video import set_offset

offset = await calibrate_offset(config.data_repo, sid)
if offset is not None:
set_offset(config.data_repo, sid, offset)
log.info("shot %06d video offset auto-calibrated to %+.2fs", sid, offset)
except Exception as exc: # noqa: BLE001 - calibration is best-effort
log.warning("offset calibration failed: %s", exc)
schedule_sync(quiet=True)
if not config.reel_enabled:
return
try:
from .render import RenderError, render_reel

reel = await render_reel(config.data_repo, sid, title=f"Shot #{sid}")
try:
await messenger.send_video(reel.read_bytes(), f"🎬 Shot #{sid}")
finally:
reel.unlink(missing_ok=True)
except RenderError as exc:
log.info("no reel for shot %06d: %s", sid, exc)
except Exception as exc: # noqa: BLE001 - never let the reel kill the bot
log.warning("reel for shot %06d failed: %s", sid, exc)

if config.camera_enabled and config.data_repo:
import shutil as _shutil
import time as _time
Expand Down
4 changes: 3 additions & 1 deletion src/matebot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"camera_enabled": "MATEBOT_CAMERA",
"camera_port": "MATEBOT_CAMERA_PORT",
"camera_offset": "MATEBOT_CAMERA_OFFSET",
"reel_enabled": "MATEBOT_REEL",
"plots_enabled": "MATEBOT_PLOTS",
"wake_hook": "MATEBOT_WAKE_HOOK",
"sleep_hook": "MATEBOT_SLEEP_HOOK",
Expand Down Expand Up @@ -71,6 +72,7 @@ class Config:
camera_enabled: bool = False # opt-in: serve the phone-camera page + record shots
camera_port: int = 8877
camera_offset: float = -1.0 # shot t=0 relative to video t=0 (detection+stream latency)
reel_enabled: bool = True # send a composed clip+chart reel after camera shots
plots_enabled: bool = True # send the shot chart as a photo (needs matplotlib)
wake_hook: str = "" # shell command to power the machine's smart plug ON
sleep_hook: str = "" # shell command to power the smart plug OFF
Expand All @@ -94,7 +96,7 @@ def load(cls, path: str | Path | None = None) -> Config:
elif f.name in ("clean_every", "water_warn_pct", "video_keep", "camera_port"):
value = int(value)
elif f.name in ("sync_enabled", "hints_enabled", "digest_enabled", "plots_enabled",
"camera_enabled"):
"camera_enabled", "reel_enabled"):
value = str(value).lower() in ("1", "true", "yes", "on")
kwargs[f.name] = value
config = cls(**kwargs)
Expand Down
5 changes: 5 additions & 0 deletions src/matebot/messengers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ async def send_photo(self, image: bytes, caption: str) -> str:
back to the caption text."""
return await self.send(caption)

async def send_video(self, video: bytes, caption: str) -> str:
"""Send a video with caption; backends without video support fall
back to the caption text."""
return await self.send(caption)

@abstractmethod
def events(self) -> AsyncIterator[Event]:
"""User interactions, in order. Runs until stop()."""
9 changes: 9 additions & 0 deletions src/matebot/messengers/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ async def send_photo(self, image: bytes, caption: str) -> str:
msg = await channel.send(caption, file=discord.File(_io.BytesIO(image), "shot.png"))
return str(msg.id)

async def send_video(self, video: bytes, caption: str) -> str:
import io as _io

import discord

channel = self.client.get_channel(self.channel_id)
msg = await channel.send(caption, file=discord.File(_io.BytesIO(video), "shot.mp4"))
return str(msg.id)

async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None:
try:
channel = self.client.get_channel(self.channel_id)
Expand Down
14 changes: 14 additions & 0 deletions src/matebot/messengers/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ async def send_photo(self, image: bytes, caption: str) -> str:
await asyncio.sleep(2 * (attempt + 1))
raise last_exc

async def send_video(self, video: bytes, caption: str) -> str:
last_exc = None
for attempt in range(5):
try:
msg = await self.app.bot.send_video(
self.chat_id, video=video, caption=caption, supports_streaming=True
)
return str(msg.message_id)
except Exception as exc: # noqa: BLE001
last_exc = exc
log.warning("video send attempt %d failed: %s", attempt + 1, exc)
await asyncio.sleep(2 * (attempt + 1))
raise last_exc

async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None:
try:
await self.app.bot.edit_message_text(
Expand Down
25 changes: 24 additions & 1 deletion src/matebot/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@


def render_shot_png(shot: Shot, *, title: str | None = None) -> bytes:
return render_shot_chart(shot, title=title)[0]


def render_shot_chart(shot: Shot, *, title: str | None = None) -> tuple[bytes, dict]:
"""Render the chart and return ``(png_bytes, geometry)``.

``geometry`` holds the pixel bounds of the data area (see render.py).
"""
import matplotlib

matplotlib.use("Agg")
Expand Down Expand Up @@ -96,5 +104,20 @@ def render_shot_png(shot: Shot, *, title: str | None = None) -> bytes:
fig.tight_layout(rect=(0, 0.08, 1, 1))
buf = io.BytesIO()
fig.savefig(buf, format="png")

# pixel geometry of the data area (needed by render.py to animate a wipe
# that tracks the x axis, not the image edges)
fig.canvas.draw()
width, height = fig.canvas.get_width_height()
end = t[-1] if t else 1.0
x0 = float(ax_temp.transData.transform((0, 0))[0])
x1 = float(ax_temp.transData.transform((end, 0))[0])
bbox = ax_temp.get_window_extent()
geometry = {
"img_w": width, "img_h": height,
"t_end": end, "x0": x0, "x1": x1,
"y_top": float(height - bbox.y1), "plot_h": float(bbox.y1 - bbox.y0),
}

plt.close(fig)
return buf.getvalue()
return buf.getvalue(), geometry
Loading
Loading