Guide
Mouth motion from YuNet landmarks finds the active speaker on a CPU, but on a podcast two-shot it switched about 4 seconds late, where clipsdk switched within half a second for $0.03 per source minute. Here is the code and why it lags.
Active speaker detection in Python on a CPU, tested against a podcast transcript
To keep the person who is talking inside a 9:16 crop, you need a score per face, 5 times a second, and a rule for when to switch.
GuidesUpdated
Or one API call
$0.03per source minute
- Every clip included, up to 1080p60 and 20 Mb/s
- $1 free to start, 33 minutes of video
- Prepaid credit, no subscription
Ways to find the active speaker
Face-only scoring runs anywhere. Audio-visual models are scored on the AVA-ActiveSpeaker benchmark and need audio, face crops and usually a GPU.
| Approach | Needs | Result | License |
|---|---|---|---|
| Mouth motion on YuNet landmarks (this guide) | OpenCV and a CPU, video only | Switched about 4 s late on a podcast two-shot | MIT model |
| TalkNet | Face crops plus audio; the demo calls CUDA | 92.3 mAP on AVA-ActiveSpeaker validation | MIT |
| Light-ASD | Face crops plus audio, 1.0M parameters | 94.06% mAP on AVA-ActiveSpeaker validation | MIT |
clipsdk reframe: "auto" | One API call | Switched within half a second of the host's first word on the same moment | Paid API |
Score each face and pick the speaker
Three steps on a CPU: cut a two-shot, measure mouth motion per face, then turn the scores into speaker turns.
Cut a two-person shot
We took 24 seconds of a NASA podcast where the guest sits on the left and the host on the right, the same exchange the API clip below ends on. YuNet runs through OpenCV, so the only model is a small ONNX file.
terminal curl -O https://api.clipsdk.com/f/lib/demo/rocket-ranch-podcast.mp4 ffmpeg -ss 367 -t 24 -i rocket-ranch-podcast.mp4 -an -c:v libx264 -crf 18 two-shot.mp4 curl -LO https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx pip install "opencv-python-headless==5.0.0.93"Measure mouth motion against head motion
YuNet gives every face a box and 5 landmarks: two eyes, the nose tip and two mouth corners. Every 0.2 s we find the faces again. On every frame we measure how much a patch around the mouth changes, and the same for a patch between the eyes and the nose. Dividing the first by the second plus 1 damps nods and head turns, which move both patches.
activity.py import json import cv2 import numpy as np detector = cv2.FaceDetectorYN.create("face_detection_yunet_2023mar.onnx", "", (640, 360), 0.6, 0.3, 16) video = cv2.VideoCapture("two-shot.mp4") fps = video.get(cv2.CAP_PROP_FPS) step = round(fps / 5) def patch(gray, cx, cy, half_w, half_h): x0, y0 = int(max(cx - half_w, 0)), int(max(cy - half_h, 0)) region = gray[y0 : int(cy + half_h) + 1, x0 : int(cx + half_w) + 1] return cv2.resize(region, (16, 16), interpolation=cv2.INTER_AREA).astype(np.float32) # The mouth, and an eyes-to-nose patch whose motion is head motion and noise def mouth_and_ref(gray, face): eyes = (face[4:6] + face[6:8]) / 2 nose = face[8:10] mouth = (face[10:12] + face[12:14]) / 2 span = max(2.0, float(np.linalg.norm(mouth - eyes))) return ( patch(gray, mouth[0], mouth[1] + 0.15 * span, 0.5 * span, 0.5 * span), patch(gray, (eyes[0] + nose[0]) / 2, (eyes[1] + nose[1]) / 2, 0.7 * span, 0.35 * span), ) faces, previous, motion, samples = {}, {}, {}, [] frame_no = 0 while True: ok, frame = video.read() if not ok: break small = cv2.resize(frame, (640, 360)) gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY) # Every 0.2 s: close the window, then find the faces again if frame_no % step == 0: if frame_no > 0: samples.append({side: m / (r + 1) for side, (m, r) in motion.items()}) _, found = detector.detect(small) found = sorted(found if found is not None else [], key=lambda f: -f[2] * f[3])[:2] faces = {("left" if f[0] + f[2] / 2 < 320 else "right"): f for f in found} motion, previous = {}, {} current = {side: mouth_and_ref(gray, f) for side, f in faces.items()} for side, (mouth, ref) in current.items(): if side in previous: m, r = motion.get(side, (0.0, 0.0)) motion[side] = (m + float(np.abs(mouth - previous[side][0]).mean()), r + float(np.abs(ref - previous[side][1]).mean())) previous = current frame_no += 1 json.dump(samples, open("activity.json", "w")) # Mean activity per face over each 2-second window for i in range(0, len(samples), 10): window = samples[i : i + 10] left = np.mean([s.get("left", 0.0) for s in window]) right = np.mean([s.get("right", 0.0) for s in window]) print(f"{i / 5:4.0f}-{(i + len(window)) / 5:<3.0f} left {left:.2f} right {right:.2f}") # $ python activity.py # 0-2 left 0.82 right 0.39 # 2-4 left 0.99 right 0.37 # 4-6 left 0.94 right 0.37 # 6-8 left 0.96 right 0.25 # 8-10 left 1.64 right 0.16 # 10-12 left 1.09 right 0.29 # 12-14 left 1.13 right 0.38 # 14-16 left 1.05 right 0.44 # 16-18 left 0.70 right 0.54 # 18-20 left 0.81 right 0.92 # 20-22 left 0.95 right 1.78 # 22-24 left 0.91 right 0.65Turn scores into speaker turns
The raw scores swap the lead 12 times in 24 seconds, so we smooth them over 7 samples, start with the face that is busiest in the first 2 seconds, and switch only when the other face leads by 1.5 times for 1.2 s, at least 2 s after the last switch. The switch is dated to when the lead began.
switch.py import json import numpy as np samples = json.load(open("activity.json")) # Smooth over 7 samples, then switch only on a 1.5x lead held for 1.2 s, at least 2 s after the last switch kernel = np.ones(7) / 7 activity = {side: np.convolve([s.get(side, 0.0) for s in samples], kernel, mode="same") for side in ("left", "right")} times = [(i + 1) / 5 for i in range(len(samples))] speaker = max(activity, key=lambda side: activity[side][:10].mean()) switches, since, lead_from = [(0.0, speaker)], 0.0, None for i, t in enumerate(times): other = "left" if speaker == "right" else "right" if activity[other][i] > 1.5 * activity[speaker][i]: lead_from = t if lead_from is None else lead_from if t - lead_from >= 1.2 and lead_from - since >= 2.0: speaker, since, lead_from = other, lead_from, None switches.append((since, speaker)) else: lead_from = None ends = [t for t, _ in switches[1:]] + [times[-1]] for (start, side), end in zip(switches, ends): print(f"{start:5.1f} {end:5.1f} {side}") # $ python switch.py # 0.0 20.8 left # 20.8 23.8 right
Or send one request
Send the podcast with aspect: "9:16" and reframe: "auto". The engine finds faces 5 times a second with YuNet, scores mouth motion against the eyes-to-nose band plus 1, smooths over 7 samples, 1.4 s, and switches only on a 1.5 times lead held for 1.2 s, at least 2 s after the last switch. Two speakers who fit in the crop together are framed together.
It returned 3 clips from the 10-minute podcast, and the third one ends on the exchange tested above. At $0.03 per source minute, billed per second, the podcast, just under 10 minutes, costs about $0.29. See pricing.
curl https://api.clipsdk.com/v1/clips \
-H "Authorization: Bearer $CLIPSDK_KEY" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://api.clipsdk.com/f/lib/demo/rocket-ranch-podcast.mp4",
"clips": 3,
"aspect": "9:16",
"reframe": "auto"
}'How it did against the transcript
The transcript of this stretch tells us who spoke when. The guest talks for the first 17 seconds, the host asks a question until 22 s, the guest answers in about a second, and the host closes from 23 s.
Our script held the guest until 21 s, about 4 seconds after the host's first word, and never cut back for her short answer. The window means in step 2 show why: while the host talked, the guest's score stayed high. Listening is not still, and smiles, nods and small reactions move the mouth area too. In this wide shot each face is only a few dozen pixels wide, so a few pixels of noise matter.
The clipsdk engine runs the same idea with more care. On the same moment its crop moved to the host half a second after his first word, and back to the guest less than half a second after her answer began.
When to use an audio-visual model
TalkNet takes the cropped face video and the audio together and scores 92.3 mAP on the AVA-ActiveSpeaker validation set. Its demo finds faces with S3FD and calls CUDA, so on a CPU you edit the code first. Light-ASD reports 94.06% mAP with 1.0M parameters and 0.6G FLOPs, against 22.5M and 2.6G for the state-of-the-art model in its paper.
Both are trained and scored on AVA-ActiveSpeaker, 3.65 million labeled frames across about 39K face tracks. They need audio in sync with each face track and a face tracker in front of them. For a crop that only has to follow the speaker, mouth motion with a strict switch rule is often enough. For a short answer like the one our script missed, the audio is what helps.
Who is talking is not who said what
Active speaker detection decides where the camera looks. It does not label speakers in a transcript, which is diarization. The clipsdk API uses the active speaker only to place the crop and returns speakers: null, so plan on a separate tool if you need names per line.
Questions, answered
Can I detect the active speaker without a GPU?
Yes. This guide runs on a CPU with YuNet through OpenCV, finding faces 5 times a second and scoring mouth motion on every frame. Audio-visual models like TalkNet also use the audio, but their demo code expects CUDA.
Why divide mouth motion by head motion?
When a speaker turns or nods, the whole face moves. Dividing by the motion of the eyes-to-nose band cancels most of that, and the 1 added below the line stops a still face from dividing by almost zero.
Why does my crop flicker between two people?
Raw scores trade the lead every few frames. Smooth them, ask for a clear lead held for over a second, and keep each speaker for at least 2 s before you switch again.
Does the clipsdk API tell me who spoke?
No. speakers is null in the result. The active speaker only decides where the 9:16 crop goes.
Keep reading
- Speaker reframe: 16:9 to 9:16 that follows whoever is talkingReframe wide video to 9:16 (1080x1920), 1:1, 4:5 or 16:9. Auto mode tracks faces and cuts to whoever is talking; center and none are one field away. $0.03 per source minute.
- Turn every podcast episode into a week of shortsUpload a podcast episode and get up to 20 vertical clips, each scored 0 to 100 with a reason, reframed to the speaker and captioned. A 60 minute episode costs ~$1.80.
- Face tracking crop from 16:9 to 9:16Track a face with OpenCV YuNet and move an ffmpeg crop with sendcmd to turn a 1920x1080 lesson into a 1080x1920 vertical clip.
- POST /v1/clips referencePOST /v1/clips takes a video URL and returns up to 20 clips of 10 to 90 s, scored 0 to 100. Defaults: 5 clips of 20 to 60 s, 9:16 at 1080x1920, bold captions, auto reframe.
- AutoFlip alternativeGoogle ended AutoFlip support in 2023. Rebuild its shot-by-shot vertical crop in Python with MediaPipe Tasks and PySceneDetect, or send one request.
- Build an OpusClip cloneThe whole clipping pipeline in 5 Python files: transcribe, pick moments with an LLM, reframe to 9:16, caption and render, with real costs.
Sources
- TalkNet-ASD on GitHub, checked 7 November 2026
- TalkNet paper, arXiv 2107.06592, checked 7 November 2026
- Light-ASD on GitHub, checked 7 November 2026
- Light-ASD paper, arXiv 2303.04439, checked 7 November 2026
- AVA-ActiveSpeaker dataset, checked 7 November 2026
- YuNet in opencv_zoo, checked 7 November 2026
- OpenCV FaceDetectorYN docs, checked 7 November 2026
Start free
Your back catalog is full of shorts
Your first $1 is free: 33 minutes of video, every clip included up to 1080p60 and 20 Mb/s.
