Is MP4 audio-video muxing possible on the OpenMV N6 (firmware v5.0.0)?

Hello,

I am currently using the OpenMV N6 running firmware v5.0.0, and I have a question regarding audio-video recording capabilities.

I can already record MJPEG video separately using the built-in mjpeg module, and capture raw PCM audio separately using the audio module. Both work as expected on their own.

However, I would like to combine both audio and video into a single playable MP4 (or another standard container) file directly on the N6 board, rather than doing post-processing on a computer.

I noticed that the product page states the N6 has a hardware H.264 encoder and supports MP4 recording. I also found the VC8000 H.264 driver source code in the official GitHub repository under drivers/vc8000/src/h264/. However, when I tried import h264 in my MicroPython script, the module was not found.

So I would like to ask:

  1. Is there currently a built-in MicroPython API or module to mux audio and video into a single MP4 file directly on the N6?
  2. Is the hardware H.264 encoder accessible via Python in the current v5.0.0 official firmware?
  3. If on-device MP4 muxing is not yet supported, what is the officially recommended workflow to produce a video file with synchronized audio?

Thank you very much for your help.

Hi, we will be adding support for H.264 output before the end of the year. If you want to rush ahead of us, you may edit the firmware yourself. An AI coder can probably get you most of what you need rather quickly. I am focused on other tasks right now than H.264 support.

Please see the docs for how to build the firmware. As for making H.264 work… it should be straightforward from the C API. The only thing to worry about is that you will need to inject cache cleaning operations into the VENC driver, as it doesn’t include these.

The AI coder will likely get everything right except the need to add these into the library. I added the required one for JPEG support. However, for H.264 support, you have to use different paths.

Thank you very much for your reply. I have one follow-up question:

Putting aside the H.264 encoding topic for now, I would like to know if it is possible with the current firmware to generatean MP4 file stored on the SD card by muxingseparately recorded MJPEG video and raw PCM audio together. I don’t specifically require H.264 compression; Ijust want to combine the existing MJPEG video track and PCM audio track into a single MP4 container file directly on the device. Is this functionality achievable with the current firmware?Has anyone already implemented this module?

I currently handle this merging process on my PC using FFmpeg.Is there any way to port FFmpeg to the OpenMV N6 board to complete this task directly.

My AI developer told me this cannot be done.

The N6 cannot run desktop applications, so you’d just want to create two separate files for right now and merge them on the PC.

Putting aside the H.264 encoding topic for now, I would like to know if it is possible with the current firmware to generatean MP4 file stored on the SD card by muxingseparately recorded MJPEG video and raw PCM audio together. I don’t specifically require H.264 compression; Ijust want to combine the existing MJPEG video track and PCM audio track into a single MP4 container file directly on the device. Is this functionality achievable with the current firmware?Has anyone already implemented this module?

Ask your AI to write the code to output an AVI file. AVI is Audio Video Interleave. Its job is to explicitly do this type of stuff. Then for the audio bit, you want to record the audio part as PCM audio. You should be able to record JPEG video and PCM audio in an AVI container.

Claude came up with this:

# avi_recorder.py - MJPEG + PCM audio AVI recorder for the OpenMV N6
#
# Records camera frames as Motion-JPEG video and the on-board MEMS
# microphone as signed-16-bit PCM audio, interleaved into a single
# standard RIFF/AVI file that plays in VLC, ffmpeg, QuickTime, etc.
#
# Tested against the OpenMV N6 board interface:
#   https://docs.openmv.io/v5.0.0/openmvcam/quickref/openmv-n6.html
#
# --------------------------------------------------------------------------
# Quick start
# --------------------------------------------------------------------------
#   import csi, time
#   from avi_recorder import AviWriter
#
#   cam = csi.CSI()
#   cam.reset()
#   cam.pixformat(csi.RGB565)      # writer JPEG-compresses each frame
#   cam.framesize(csi.QVGA)        # 320x240
#
#   avi = AviWriter("recording.avi", 320, 240, quality=90,
#                   audio_frequency=16000, audio_channels=1)
#
#   clock = time.clock()
#   for _ in range(300):           # ~record until you decide to stop
#       clock.tick()
#       avi.add_frame(cam.snapshot())
#
#   avi.close()                    # finalizes header + index, stops audio
#
# Or as a context manager:
#   with AviWriter("clip.avi", 320, 240) as avi:
#       for _ in range(300):
#           avi.add_frame(cam.snapshot())
#
# --------------------------------------------------------------------------
# Notes
# --------------------------------------------------------------------------
# * Video FPS is *measured* from wall-clock time between the first and last
#   frame, then written back into the header on close(). You do not need to
#   know the frame rate in advance.
# * Audio is captured asynchronously via audio.start_streaming(); buffers are
#   queued and flushed (interleaved) just before each video frame is written,
#   so A/V stays roughly in sync. Any tail audio is flushed on close().
# * add_frame() accepts either an OpenMV image.Image (RGB565/GRAYSCALE - it is
#   JPEG compressed for you) or a bytes/bytearray of an already-encoded JPEG
#   (e.g. when the sensor is configured for csi.JPEG pixformat: pass
#   img.bytearray()).
# * Only 16-bit PCM is emitted (that is what the audio module produces).
 
import struct
import time
 
try:
    import audio
except ImportError:  # allows importing the module on a board without audio
    audio = None
 
 
class AviWriter:
    # AVIMAINHEADER.dwFlags
    _AVIF_HASINDEX = 0x00000010
    _AVIF_ISINTERLEAVED = 0x00000100
    # AVIINDEXENTRY.dwFlags
    _AVIIF_KEYFRAME = 0x00000010
 
    def __init__(self, path, width, height, quality=90,
                 record_audio=True, audio_channels=1,
                 audio_frequency=16000, audio_gain_db=24):
        self.path = path
        self.width = int(width)
        self.height = int(height)
        self.quality = int(quality)
 
        self.record_audio = bool(record_audio) and (audio is not None)
        self.audio_channels = int(audio_channels)
        self.audio_frequency = int(audio_frequency)
        self.audio_gain_db = int(audio_gain_db)
        self._bits = 16
        self._block_align = self.audio_channels * (self._bits // 8)
        self._avg_bps = self.audio_frequency * self._block_align
 
        # running state
        self.frames = 0
        self._audio_bytes = 0
        self._index = []          # list of (fourcc, flags, movi_offset, size)
        self._movi_off = 0        # bytes written into the movi payload so far
        self._audio_q = []        # queued PCM buffers from the streaming cb
        self._max_jpeg = 0
        self._max_audio = 0
        self._t0 = None
        self._t1 = None
        self._closed = False
 
        # Open file and reserve the (fixed-size) preamble. It is rewritten
        # with the final rates/counts in close().
        self._f = open(path, "wb")
        self._preamble_len = self._write_preamble(0.0, 0, 0)
        self._movi_data_start = self._f.tell()
 
        if self.record_audio:
            audio.init(channels=self.audio_channels,
                       frequency=self.audio_frequency,
                       gain_db=self.audio_gain_db)
            audio.start_streaming(self._audio_cb)
 
    # ------------------------------------------------------------------ #
    # Public API
    # ------------------------------------------------------------------ #
    def add_frame(self, img, quality=None):
        """Append one video frame. `img` is an OpenMV image (compressed here)
        or bytes/bytearray of an already-encoded JPEG."""
        if self._closed:
            raise OSError("AviWriter is closed")
 
        now = time.ticks_us()
        if self._t0 is None:
            self._t0 = now
        self._t1 = now
 
        if isinstance(img, (bytes, bytearray, memoryview)):
            data = img
        else:
            q = self.quality if quality is None else int(quality)
            # compress() JPEG-encodes the frame in place and returns it.
            data = img.compress(quality=q).bytearray()
 
        # Interleave: flush audio captured up to this point, then the frame.
        self._flush_audio()
        self._write_chunk(b"00dc", data, keyframe=True)
        self.frames += 1
        n = len(data)
        if n > self._max_jpeg:
            self._max_jpeg = n
 
    def close(self):
        """Stop audio, flush, write the index, patch the header, close file."""
        if self._closed:
            return
        self._closed = True
 
        if self.record_audio:
            try:
                audio.stop_streaming()
            except Exception:
                pass
            self._flush_audio()   # drain any tail buffers
 
        self._write_index()
        self._finalize()
        self._f.close()
 
    # context manager sugar
    def __enter__(self):
        return self
 
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()
        return False
 
    # ------------------------------------------------------------------ #
    # Audio capture
    # ------------------------------------------------------------------ #
    def _audio_cb(self, pcmbuf):
        # The driver reuses pcmbuf, so copy the bytes out immediately.
        self._audio_q.append(bytes(pcmbuf))
 
    def _flush_audio(self):
        if not self._audio_q:
            return
        # Detach the queue so a concurrent callback appends to a fresh list.
        q = self._audio_q
        self._audio_q = []
        for buf in q:
            n = len(buf)
            if not n:
                continue
            self._write_chunk(b"01wb", buf, keyframe=False)
            self._audio_bytes += n
            if n > self._max_audio:
                self._max_audio = n
 
    # ------------------------------------------------------------------ #
    # RIFF chunk writing
    # ------------------------------------------------------------------ #
    def _write_chunk(self, fourcc, data, keyframe):
        n = len(data)
        # idx1 offset is relative to the 'movi' FOURCC (which sits 4 bytes
        # before the first payload chunk), hence +4.
        off = self._movi_off + 4
        f = self._f
        f.write(fourcc)
        f.write(struct.pack("<I", n))
        f.write(data)
        pad = n & 1               # chunks are word-aligned
        if pad:
            f.write(b"\x00")
        self._index.append(
            (fourcc, self._AVIIF_KEYFRAME if keyframe else 0, off, n))
        self._movi_off += 8 + n + pad
 
    def _write_index(self):
        f = self._f
        f.write(b"idx1")
        f.write(struct.pack("<I", 16 * len(self._index)))
        for fourcc, flags, off, size in self._index:
            f.write(fourcc)
            f.write(struct.pack("<III", flags, off, size))
 
    # ------------------------------------------------------------------ #
    # Header (preamble) - fixed size, written twice (placeholder + final)
    # ------------------------------------------------------------------ #
    def _finalize(self):
        file_size = self._f.tell()
        movi_list_size = 4 + self._movi_off      # 'movi' fourcc + payload
        riff_size = file_size - 8
 
        if self.frames >= 2 and self._t0 is not None:
            secs = time.ticks_diff(self._t1, self._t0) / 1000000.0
            fps = (self.frames - 1) / secs if secs > 0 else 30.0
        else:
            fps = 30.0
 
        self._f.seek(0)
        self._write_preamble(fps, movi_list_size, riff_size)
        self._f.seek(file_size)
 
    def _write_preamble(self, fps, movi_list_size, riff_size):
        hdrl = self._build_hdrl(fps)
        buf = bytearray()
        # RIFF header
        buf += b"RIFF" + struct.pack("<I", riff_size) + b"AVI "
        # hdrl LIST
        buf += b"LIST" + struct.pack("<I", len(hdrl) + 4) + b"hdrl" + hdrl
        # movi LIST header (payload follows this preamble)
        buf += b"LIST" + struct.pack("<I", movi_list_size) + b"movi"
        self._f.write(buf)
        return len(buf)
 
    def _build_hdrl(self, fps):
        streams = 2 if self.record_audio else 1
        micro = int(round(1000000.0 / fps)) if fps > 0 else 33333
        rate = int(round(fps * 1000)) if fps > 0 else 30000
        scale = 1000
        max_bytes_per_sec = (self._avg_bps +
                             int(self._max_jpeg * (fps if fps > 0 else 30)))
        suggested = max(self._max_jpeg, self._max_audio, 1)
 
        # --- avih (AVIMAINHEADER, 56 bytes body) ---
        avih_body = struct.pack(
            "<14I",
            micro,                       # dwMicroSecPerFrame
            max_bytes_per_sec,           # dwMaxBytesPerSec
            0,                           # dwPaddingGranularity
            self._AVIF_HASINDEX | self._AVIF_ISINTERLEAVED,  # dwFlags
            self.frames,                 # dwTotalFrames
            0,                           # dwInitialFrames
            streams,                     # dwStreams
            suggested,                   # dwSuggestedBufferSize
            self.width,                  # dwWidth
            self.height,                 # dwHeight
            0, 0, 0, 0)                  # dwReserved[4]
        avih = b"avih" + struct.pack("<I", len(avih_body)) + avih_body
 
        # --- video stream list (vids / MJPG) ---
        vid_strh_body = struct.pack(
            "<4s4sI2H8I4h",
            b"vids", b"MJPG",
            0,                           # dwFlags
            0, 0,                        # wPriority, wLanguage
            0,                           # dwInitialFrames
            scale,                       # dwScale
            rate,                        # dwRate  (rate/scale = fps)
            0,                           # dwStart
            self.frames,                 # dwLength (frames)
            max(self._max_jpeg, 1),      # dwSuggestedBufferSize
            0,                           # dwQuality
            0,                           # dwSampleSize (0 = variable)
            0, 0, self.width, self.height)   # rcFrame
        vid_strh = b"strh" + struct.pack("<I", len(vid_strh_body)) + vid_strh_body
 
        vid_strf_body = struct.pack(
            "<IiiHH4sIiiII",
            40,                          # biSize
            self.width,                  # biWidth
            self.height,                 # biHeight
            1,                           # biPlanes
            24,                          # biBitCount
            b"MJPG",                     # biCompression
            self.width * self.height * 3,  # biSizeImage
            0, 0,                        # biX/YPelsPerMeter
            0, 0)                        # biClrUsed, biClrImportant
        vid_strf = b"strf" + struct.pack("<I", len(vid_strf_body)) + vid_strf_body
 
        vid_strl_body = b"strl" + vid_strh + vid_strf
        vid_strl = b"LIST" + struct.pack("<I", len(vid_strl_body)) + vid_strl_body
 
        hdrl = avih + vid_strl
 
        # --- audio stream list (auds / PCM) ---
        if self.record_audio:
            aud_blocks = self._audio_bytes // self._block_align
            aud_strh_body = struct.pack(
                "<4s4sI2H8I4h",
                b"auds", b"\x00\x00\x00\x00",
                0,                       # dwFlags
                0, 0,                    # wPriority, wLanguage
                0,                       # dwInitialFrames
                self._block_align,       # dwScale
                self._avg_bps,           # dwRate (rate/scale = samples/sec)
                0,                       # dwStart
                aud_blocks,              # dwLength (in blocks/samples)
                max(self._max_audio, 1),  # dwSuggestedBufferSize
                0,                       # dwQuality
                self._block_align,       # dwSampleSize
                0, 0, 0, 0)              # rcFrame (unused for audio)
            aud_strh = b"strh" + struct.pack("<I", len(aud_strh_body)) + aud_strh_body
 
            aud_strf_body = struct.pack(
                "<HHIIHH",
                1,                       # wFormatTag = WAVE_FORMAT_PCM
                self.audio_channels,     # nChannels
                self.audio_frequency,    # nSamplesPerSec
                self._avg_bps,           # nAvgBytesPerSec
                self._block_align,       # nBlockAlign
                self._bits)              # wBitsPerSample
            aud_strf = b"strf" + struct.pack("<I", len(aud_strf_body)) + aud_strf_body
 
            aud_strl_body = b"strl" + aud_strh + aud_strf
            aud_strl = b"LIST" + struct.pack("<I", len(aud_strl_body)) + aud_strl_body
            hdrl += aud_strl
 
        return hdrl

Which you then use like:

import csi, time
from avi_recorder import AviWriter

cam = csi.CSI()
cam.reset()
cam.pixformat(csi.RGB565)     # writer JPEG-compresses each frame
cam.framesize(csi.QVGA)       # 320x240

avi = AviWriter("recording.avi", 320, 240, quality=90,
                audio_frequency=16000, audio_channels=1)

record_secs = 10
target_fps = 30
frame_ms = 1000 // target_fps

start = time.ticks_ms()
while time.ticks_diff(time.ticks_ms(), start) < record_secs * 1000:
    t = time.ticks_ms()
    avi.add_frame(cam.snapshot())
    dt = time.ticks_diff(time.ticks_ms(), t)
    if dt < frame_ms:
        time.sleep_ms(frame_ms - dt)

avi.close()                   # finalizes header + index, stops audio

A few design points worth flagging:

FPS is measured, not assumed. add_frame() timestamps with time.ticks_us(), and close() seeks back and rewrites the fixed-size preamble with the real frame rate, total frame count, and audio length — so you don’t have to declare a frame rate up front and it stays honest even if capture rate drifts.

Audio comes in through audio.start_streaming(self._audio_cb); the callback copies each buffer (bytes(pcmbuf), since the driver reuses it) into a queue, and the queue is flushed as 01wb chunks immediately before each 00dc video chunk. That keeps the interleave roughly time-ordered, and any tail buffers are drained after stop_streaming() in close(). Video length is derived from wall-clock time while audio length comes from the actual sample count, so the two durations line up.

add_frame() takes either an image.Image (RGB565/GRAYSCALE — it calls img.compress(quality=...).bytearray() for you) or raw JPEG bytes.

The index (idx1) is written with offsets relative to the movi FOURCC and AVIIF_KEYFRAME set on every JPEG frame, which is what makes it seekable in VLC/QuickTime rather than just linearly playable.

One thing to sanity-check on real hardware: I stubbed the audio module for the host test, so confirm your firmware build’s exact audio API (audio.init(channels=, frequency=, gain_db=) / start_streaming / stop_streaming) matches — the docs snippet I pulled shows exactly that signature, but if stop_streaming is named differently in v5.0.0 the close() call is the only spot to adjust.

Sources: OpenMV N6 board interface

recording.zip (20.9 MB)

Thanks for your reply, this is very helpful. Video recording runs smoothly. I am currently attempting to transfer files directly to my computer using the Wi-Fi module. I have encountered an issue though: the Wi-Fi connection works normally using raw TCP, but the connection cannot be established when I switch to HTTP. Could you help me troubleshoot this?

HTTP
import csi, time, os, gc
import network, socket
from avi_recorder import AviWriter
from machine import LED

# ============================================================
# CONFIGURATION – fill in your actual values
# ============================================================
OUTPUT_FILE   = "/sdcard/hd_record.avi"   # File path on SD card
RECORD_SECS   = 6                         # Recording duration (seconds)
QUALITY       = 70                        # JPEG quality 1-100 (lower = smaller file, higher fps)

WIFI_SSID     = "YOUR_WIFI_SSID"          # 2.4 GHz Wi‑Fi name
WIFI_PASS     = "YOUR_WIFI_PASSWORD"      # Wi‑Fi password
SERVER_URL    = "http://YOUR_PC_IP:12345/upload"  # HTTP upload endpoint (http:// only)
API_KEY       = ""                        # Bearer token if required, otherwise leave empty
# ============================================================

# ============================================================
# Onboard RGB LED status definitions
# ============================================================
led_red   = LED("LED_RED")
led_green = LED("LED_GREEN")
led_blue  = LED("LED_BLUE")

def led(state):
    """Set LED colours for quick visual feedback of system state."""
    led_red.off(); led_green.off(); led_blue.off()
    if state == "ready":   led_green.on()                     # Green – ready
    elif state == "rec":   led_red.on()                       # Red – recording
    elif state == "wifi":  led_green.on(); led_blue.on()      # Cyan – Wi‑Fi / uploading
    elif state == "done":  led_blue.on()                      # Blue – success
    elif state == "err":   led_red.on(); led_green.on()       # Yellow (red+green) – error

# ============================================================
# 1. Camera setup (no audio yet)
# ============================================================
led("ready")
cam = csi.CSI()
cam.reset()
cam.pixformat(csi.RGB565)          # 16-bit colour
cam.framesize(csi.HD)              # 1280×720
cam.snapshot(time=2000)            # 2-second warm‑up to stabilise exposure

# ============================================================
# 2. Countdown before recording
# ============================================================
print("Starting in 2 seconds...")
time.sleep(2)
print("===== Recording started =====")

# ============================================================
# 3. Create AVI writer (audio starts now) and record
# ============================================================
try:
    avi = AviWriter(OUTPUT_FILE, width=1280, height=720,
                    quality=QUALITY, audio_frequency=16000, audio_channels=1)
except Exception as e:
    print("AVI creation failed:", e)
    led("err")
    while True: time.sleep(1)

led("rec")
frame_cnt = 0
start_ms = time.ticks_ms()

while time.ticks_diff(time.ticks_ms(), start_ms) < RECORD_SECS * 1000:
    avi.add_frame(cam.snapshot())
    frame_cnt += 1

actual_ms = time.ticks_diff(time.ticks_ms(), start_ms)
actual_fps = frame_cnt / (actual_ms / 1000) if actual_ms > 0 else 15
avi.set_fps(actual_fps)   # write true frame rate to header → perfect A/V sync
avi.close()               # stop audio, finalise index and file headers

print(f"Actual FPS: {actual_fps:.1f}, total frames: {frame_cnt}")
print(f"Recording duration: {actual_ms/1000:.1f}s")

# ============================================================
# 4. Verify the file exists on SD card
# ============================================================
try:
    if "hd_record.avi" not in os.listdir("/sdcard"):
        print("Error: file not created")
        led("err")
        while True: time.sleep(1)
    file_size = os.stat(OUTPUT_FILE)[6]
    print(f"File size: {file_size} bytes")
except Exception as e:
    print("File check failed:", e)
    led("err")
    while True: time.sleep(1)

# ============================================================
# 5. Wi‑Fi connection (with memory cleanup and retry)
# ============================================================
led("wifi")
print("Connecting to Wi‑Fi...")

gc.collect()                # free memory before network stack initialisation
time.sleep(0.5)

wlan = network.WLAN(network.STA_IF)
wlan.active(False)          # reset interface
time.sleep_ms(200)
wlan.active(True)
wlan.connect(WIFI_SSID, WIFI_PASS)

# First attempt – wait up to 15 seconds
wifi_cnt = 0
while not wlan.isconnected() and wifi_cnt < 30:
    time.sleep_ms(500)
    print(".", end="")
    wifi_cnt += 1

# Retry once if first attempt fails
if not wlan.isconnected():
    print("\nFirst attempt failed, retrying after 2s...")
    time.sleep(2)
    gc.collect()
    wlan.active(False)
    time.sleep_ms(200)
    wlan.active(True)
    wlan.connect(WIFI_SSID, WIFI_PASS)
    retry_cnt = 0
    while not wlan.isconnected() and retry_cnt < 30:
        time.sleep_ms(500)
        print(".", end="")
        retry_cnt += 1

    if not wlan.isconnected():
        print("\nWi‑Fi connection failed. File remains on SD card.")
        led("err")
        while True: time.sleep(1)

print(f"\nWi‑Fi connected. Device IP: {wlan.ifconfig()[0]}")
gc.collect()  # another cleanup before the upload

# ============================================================
# 6. Streaming HTTP upload (avoids loading the whole file into RAM)
# ============================================================
print(f"Uploading to {SERVER_URL} ...")

# ----- Parse the server URL -----
url = SERVER_URL.replace("http://", "")
if "/" in url:
    host_port, path = url.split("/", 1)
    path = "/" + path
else:
    host_port, path = url, "/"
if ":" in host_port:
    host, port_str = host_port.split(":", 1)
    port = int(port_str)
else:
    host, port = host_port, 80

# ----- Build multipart/form-data envelope -----
boundary = "----N6Up"
head = (
    f"--{boundary}\r\n"
    'Content-Disposition: form-data; name="file"; filename="hd_record.avi"\r\n'
    "Content-Type: application/octet-stream\r\n\r\n"
).encode()
tail = f"\r\n--{boundary}--\r\n".encode()
body_len = len(head) + file_size + len(tail)

header = (
    f"POST {path} HTTP/1.1\r\n"
    f"Host: {host}\r\n"
    f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
    f"Content-Length: {body_len}\r\n"
)
if API_KEY:
    header += f"Authorization: Bearer {API_KEY}\r\n"
header += "Connection: close\r\n\r\n"

try:
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(30)
    sock.connect((host, port))

    # Send headers and multipart head
    sock.send(header.encode())
    sock.send(head)

    # Send file in 4 KB chunks (never loads the entire file into memory)
    with open(OUTPUT_FILE, "rb") as f:
        while True:
            chunk = f.read(4096)
            if not chunk:
                break
            sock.send(chunk)

    # Finalise multipart body
    sock.send(tail)

    # Read server response
    response = b""
    try:
        while True:
            chunk = sock.recv(1024)
            if not chunk:
                break
            response += chunk
    except:
        pass
    sock.close()

    # Check if the server signalled success (optional)
    if b"200" in response or b"ok" in response:
        led("done")
        print("✅ Upload successful")
    else:
        print("Server response:", response[:200])
        led("err")
except Exception as e:
    print(f"Upload failed: {e}")
    print("File remains on SD card – transfer it manually.")
    led("err")

# Keep the board idle after finishing
while True:
time.sleep(1)

Hi Kui,

HTTP runs on top of TCP — the connect() call in both of your scripts is doing exactly the same thing. So if the raw-TCP script connects and the HTTP script doesn’t, the difference isn’t the protocol, it’s what’s listening on the PC. Check these first:

  1. Is your HTTP server actually running when the camera connects? Your raw-TCP test used a custom receiver script on port 12345 — the HTTP test needs a different server program listening on that same port.
  2. Is it bound to 0.0.0.0 and not 127.0.0.1/localhost? A server bound to localhost accepts connections only from the PC itself; the camera will get “connection refused.”
  3. Windows Firewall prompts per-program. Your TCP receiver may have been allowed already while the new HTTP server got silently blocked. Allow Python through the firewall for private networks (or temporarily disable it to test).
  4. Note: python -m http.server won’t work — it only serves GET requests, it can’t accept uploads.

Here’s a minimal PC-side upload server that matches your camera script (save as upload_server.py, run with python upload_server.py):slight_smile:

from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers["Content-Length"])
        data = self.rfile.read(length)
        # Strip the multipart envelope to recover the raw AVI bytes
        boundary = self.headers["Content-Type"].split("boundary=")[1].encode()
        start = data.find(b"\r\n\r\n") + 4
        end = data.rfind(b"\r\n--" + boundary + b"--")
        with open("hd_record.avi", "wb") as f:
            f.write(data[start:end])
        print(f"Received {end - start} bytes")
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok")

print("Listening on 0.0.0.0:12345 ...")
HTTPServer(("0.0.0.0", 12345), Handler).serve_forever()

A few fixes for your camera script too:

1. Use safe_send() for the HTTP upload as well. Your raw-TCP script correctly loops until all bytes are sent, but the HTTP script uses bare sock.send(), which is allowed to send fewer bytes than you gave it. On a large file you’ll eventually get a short send and a corrupted upload. Replace every sock.send(...) in section 6 with the same safe_send(sock, ...) helper from your TCP script.

2. Resolve the address with getaddrinfo(). It’s more portable and lets you use hostnames, not just IPs:

addr = socket.getaddrinfo(host, port)[0][-1]
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(30)
sock.connect(addr)

3. Fix the indentation at the very end of the script — the last two lines as posted are:

while True:
time.sleep(1)

time.sleep(1) needs to be indented, otherwise the script dies with an IndentationError before it even starts (if this is just a copy-paste artifact, ignore this one).

With the server above running and the firewall open, your script should work — I’d suggest testing connectivity first with a short recording (RECORD_SECS = 2) so you’re not waiting on a big file each attempt.

The code I shared this time isn’t polished. I’ll send you a standardized version tomorrow for your review.

Hi, you can use this script to test transfer performance: micropython-lib/python-ecosys/iperf3/iperf3.py at master · micropython/micropython-lib · GitHub

You can run it on linux and micropython. With it you can test max transfer speeds. Yes, how you write code matters. Note that TCP performance isn’t extremely high out of the box as it requires massive LWIP buffers.

I reproduced your slow upload on an N6 here and tracked it down. Three separate things were stacking up, and one of them is genuinely non-obvious:

1. gc.collect() per chunk. Your earlier script called gc.collect() after every 4 KB send. On the N6’s SDRAM-sized heap each collect takes tens of ms, so that alone caps you around 50 KB/s. Your later script fixed this with readinto() into a preallocated buffer — keep that.

2. WiFi power save. Disable it before uploading:

wlan.config(pm=network.WLAN.PM_NONE)

3. The non-obvious one: don’t fine-grain interleave SD reads with socket sends. This is what was still killing throughput even with clean code. Measured on my bench (N6, quiet 2.4 GHz network):

Upload from RAM only (no SD)  : ~600 KB/s, steady
SD read only (no network)     : 6-16 MB/s, no stalls
Upload from SD, 64 KB reads   : ~136 KB/s, multi-second stalls
Upload from SD, 256 KB reads  : ~560 KB/s
Upload from SD, 4 MB reads    : ~612 KB/s

Each part is fast on its own, but alternating small SD reads with sends makes TCP stall (it looks like retransmit-timeout backoff — bursts at full speed, then ~1 s per packet). Reading from the card in 256 KB or larger chunks between sends restores full link speed. We’ll look into the underlying firmware interaction, but the workaround is simple and you want big reads anyway.

Also: keep one connection and one POST for the whole file instead of a PATCH request per 1 MB chunk — each reconnect restarts TCP slow-start and your backoff timer. And use socket.write() rather than send(); write() already loops until everything is sent, so you don’t need safe_send().

Here’s the upload function that hits ~600 KB/s on my bench. Note it sends the file as a plain octet-stream POST (simpler and faster than multipart), so use the matching receiver script below rather than your multipart server:

def upload(path, host, port):
    size = os.stat(path)[6]
    addr = socket.getaddrinfo(host, port)[0][-1]
    s = socket.socket()
    s.connect(addr)
    try:
        s.write(("POST /upload HTTP/1.1\r\n"
                 "Host: %s\r\n"
                 "Content-Type: application/octet-stream\r\n"
                 "X-Filename: %s\r\n"
                 "Content-Length: %d\r\n"
                 "Connection: close\r\n\r\n" % (host, path, size)).encode())
        buf = bytearray(262144)   # >= 256 KB is the important part
        mv = memoryview(buf)
        with open(path, "rb") as f:
            while True:
                n = f.readinto(buf)
                if not n:
                    break
                s.write(mv[:n])
        return s.read(512)        # server reply ("Connection: close" -> EOF)
    finally:
        s.close()

One more expectation-setting note: ~600 KB/s is about the realistic TCP uplink ceiling for the N6’s WiFi. The limit is a roughly fixed cost per packet, not per byte — each 1460-byte segment pays for a copy into the stack, a software TCP checksum, its own SDIO transaction to the radio, and servicing the ACKs coming back on the same bus, which works out to ~2 ms per segment. That’s why raising TCP windows/buffers doesn’t help (the sender can’t produce packets any faster) and why most MicroPython WiFi boards land in the same 0.5-1 MB/s range. With the fixes above, the 3-minute clip that took you 20+ minutes should upload in under 2 minutes.

What we can improve is enabling the H.264 feature on the N6, which will dramatically decrease the clip file size. I plan to have that feature implemented in the next two months.

upload_server.py (3.1 KB)

forum_n6_record_upload.py (3.3 KB)

avi_recorder.py (13.3 KB)

If you are able to use UDP the performance is much higher. Then, with ethernet, there’s much more hardware offload for the CPU with UDP giving an even bigger performance boost.

Follow-up with the deeper “why” on the WiFi TCP throughput limit, since this applies to anyone moving bulk data (video, datasets, OTA files) off a MicroPython board and it’s worth being findable:

The TCP ceiling is a per-packet cost, not a per-byte cost

At ~600 KB/s the N6 is sending about 420 TCP segments per second (1460 bytes each). That means the whole pipeline — Python, lwIP, the WiFi driver, the SDIO bus — spends a roughly fixed ~2.4 ms of work and waiting per segment. Throughput is simply 1460 bytes divided by that per-packet cost. Halve the cost and you double the speed; nothing else moves it.

Where the time per packet goes

  1. Copies and checksum. tcp_write() copies your data into the stack’s buffers, then computes the TCP checksum in software (the radio has no checksum offload). On an 800 MHz Cortex-M55 this is only a few microseconds per packet — this is NOT the bottleneck, which is exactly why a fast CPU doesn’t save you.

  2. The bus transaction. The driver wraps each frame in its transport headers and performs one SDIO transaction per frame, one frame at a time, with no transmit queue depth. Command setup and bus latency are paid per packet.

  3. Flow-control coupling. The host may only transmit when the radio firmware grants it credits, and credit updates arrive via receive interrupts — so transmit pacing also waits on interrupt latency and scheduler hops, per packet.

  4. ACK servicing. Every couple of segments, an ACK comes back: a bus read plus an lwIP input pass, stealing the same serialized bus and CPU time the transmit path needs.

Why bigger TCP windows/buffers don’t help

Windows fix in-flight limits: they matter when RTT x target-rate exceeds the window (long or high-latency pipes). The default window here (~23 KB) at typical LAN RTT (~5 ms) already permits ~4.6 MB/s — about 7x what we actually observe. The pipe is never full; the sender simply can’t produce segments any faster. Growing the send buffer just gives a starved producer a bigger empty queue. We’ve been down this road — window sizes, buffer counts, pool sizes — and it always comes back to per-packet CPU and bus overhead.

The fingerprints of a per-packet limit

  • UDP beats TCP, because there are no ACKs to service — the receive half of the per-packet cost disappears.
  • Downlink (PC → camera) is weaker than uplink, because receiving costs the host more per packet (bus read, stack input, and generating ACKs that compete with incoming data for the same bus), and the MCU can’t backpressure the radio when it falls behind.
  • Throughput barely scales with CPU clock. A Pico W at 133 MHz and the N6 at 800 MHz use the same driver architecture, and 6x the clock buys nowhere near 6x the throughput — bus latency, interrupt overhead, and protocol turnarounds don’t scale with MHz.

What would actually raise the ceiling

Structural driver work, not tuning: batching multiple frames per bus transaction, zero-copy transmit with the checksum folded into the single copy pass, and deeper transmit queueing. To set expectations: the WiFi driver we build on is the same well-proven upstream driver the whole MicroPython ecosystem shares across many boards, and improvements at this level touch its core architecture — they have to be designed and validated for every port that depends on it, not just ours. That’s the right way to do it, but it means these gains land on the timescale of upstream releases, not a quick firmware patch from us. Until then, plan for roughly 0.5-1 MB/s TCP on MicroPython WiFi boards and design around streaming within that budget — which, for what it’s worth, still comfortably carries QVGA MJPEG+audio in real time.