Saving PCIbex recordings to S3 with a Python Lambda
This guide shows how to send PCIbex / PennController audio recordings directly to Amazon S3 using a Python AWS Lambda fronted by API Gateway. The goal is to have PCIbex do:
InitiateRecorder(<your-url>)at the start of the experiment.UploadRecordings(...)between trials.- Your Lambda receives the uploaded ZIP and stores it in S3.
No extra JavaScript in PCIbex, no PHP server.
Architecture
- PCIbex runs in the browser and records audio.
- It contacts API Gateway at your URL.
- API Gateway triggers a Python Lambda.
- Lambda accepts multipart/form-data from PCIbex and saves the file to S3.
- Lambda returns
{ "ok": true }so PCIbex continues.
We’ll build this step by step.
1. Create the S3 bucket
- Open AWS Console → S3 → Create bucket.
- Name it (example):
pcibex-recordings-demo-2025. - Choose the same Region you will use for the Lambda (for example,
us-east-2). - Keep Block all public access enabled.
- Create the bucket.
We’ll tell Lambda this bucket name via environment variables.
2. Create the Lambda function (Python)
- Go to AWS Lambda → Create function.
- Name:
pcibex-s3-recorder. - Runtime: Python 3.11 (or newer if available).
- Create the function.
Replace the default code with this:
import os
import json
import base64
import uuid
import boto3
import logging
# basic logger for CloudWatch
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# s3 client
s3 = boto3.client("s3")
# environment-configurable values
BUCKET_NAME = os.environ.get("BUCKET_NAME", "pcibex-recordings-demo-2025")
ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "https://farm.pcibex.net")
def _cors_headers():
return {
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "OPTIONS,GET,POST",
"Access-Control-Allow-Headers": "content-type",
}
def _store_in_s3(content: bytes, filename: str) -> str:
# give each upload a unique prefix
key = f"{uuid.uuid4()}_{filename}"
s3.put_object(
Bucket=BUCKET_NAME,
Key=key,
Body=content,
ContentType="application/zip",
)
return key
def _parse_multipart(event):
"""Parse a simple multipart/form-data upload from API Gateway HTTP API.
PCIbex sends the file part under the name "file".
"""
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
ctype = headers.get("content-type")
if not ctype or "multipart/form-data" not in ctype:
raise ValueError("Expected multipart/form-data")
# extract boundary
boundary = ctype.split("boundary=")[1].strip().strip('"').encode()
# body comes base64-encoded from API Gateway for binary/multipart
body = event.get("body", "")
if event.get("isBase64Encoded"):
body = base64.b64decode(body)
else:
body = body.encode()
delimiter = b"--" + boundary
sections = body.split(delimiter)
file_bytes = None
filename = "recordings.zip"
for sec in sections:
if not sec or sec in (b"--", b"--\r\n"):
continue
head, _, data = sec.partition(b"\r\n\r\n")
if not data:
continue
# trim trailing CRLF and optional --
data = data.rstrip(b"\r\n")
if data.endswith(b"--"):
data = data[:-2]
head_text = head.decode(errors="ignore")
if 'name="file"' in head_text:
# get filename if present
for line in head_text.split("\r\n"):
if "filename=" in line:
filename = line.split("filename=", 1)[1].strip().strip('"')
file_bytes = data
break
if file_bytes is None:
raise ValueError("No file part named 'file' found")
return filename, file_bytes
def lambda_handler(event, context):
# detect HTTP method from API Gateway v2 (HTTP API)
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
logger.info(f"method={method}")
# 1. handle CORS preflight
if method == "OPTIONS":
return {
"statusCode": 200,
"headers": _cors_headers(),
"body": ""
}
# 2. PCIbex calls the URL once at start with GET
if method == "GET":
return {
"statusCode": 200,
"headers": _cors_headers(),
"body": json.dumps({"ok": True})
}
# 3. actual upload: POST multipart/form-data
if method == "POST":
try:
filename, data = _parse_multipart(event)
key = _store_in_s3(data, filename)
return {
"statusCode": 200,
"headers": _cors_headers(),
"body": json.dumps({"ok": True, "key": key})
}
except Exception as e:
logger.exception("Upload failed")
return {
"statusCode": 400,
"headers": _cors_headers(),
"body": json.dumps({"ok": False, "error": str(e)})
}
# anything else: not allowed
return {
"statusCode": 405,
"headers": _cors_headers(),
"body": json.dumps({"error": "Method not allowed"})
}Environment variables
Still in the Lambda console, set:
BUCKET_NAME = pcibex-recordings-demo-2025ALLOWED_ORIGIN = https://farm.pcibex.net
so you can use the same code everywhere.
3. Give Lambda S3 permissions
The Lambda needs to write objects to your bucket.
- Go to the Lambda’s Configuration → Permissions.
- Open the execution role in IAM.
- Add this inline policy (adjust the bucket name):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowUploadToBucket",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:PutObjectAcl"
],
"Resource": "arn:aws:s3:::pcibex-recordings-demo-2025/*"
}
]
}Save.
4. Expose the Lambda over HTTP (API Gateway)
- In the Lambda page, click Add trigger.
- Choose API Gateway.
- API type: HTTP API.
- Security: Open (you can restrict later).
- Create.
AWS gives you a URL like:
https://abc123.execute-api.us-east-2.amazonaws.com/default/pcibex-s3-recorder
This is the URL we will give to PCIbex.
5. Enable CORS on API Gateway
In API Gateway, configure CORS with:
- Allowed origin:
https://farm.pcibex.net - Allowed methods:
GET,POST,OPTIONS - Allowed headers:
content-type - Allow credentials: true
Save (and Deploy if you are using a REST API).
This lets the PCIbex page call your API Gateway endpoint from the browser.
6. PCIbex / PennController script
In your PCIbex script:
PennController.ResetPrefix(null);
const LAMBDA_URL = "https://abc123.execute-api.us-east-2.amazonaws.com/default/pcibex-s3-recorder";
Sequence(
"init",
sepWith("async", rshuffle("letter","picture"))
);
InitiateRecorder(LAMBDA_URL).label("init");
UploadRecordings("async", "noblock");
// ... your trials ...This is the standard PennController pattern:
InitiateRecorder(...)makes a GET to the URL → Lambda returns{ "ok": true }.UploadRecordings(...)sends recorded files with POST multipart/form-data → Lambda parses and stores to S3 → returns{ "ok": true, "key": "..." }.
No extra JS is needed.
7. Test the whole pipeline
- Open the API Gateway URL in your browser — you should see:
{"ok": true}- Run your PCIbex experiment, record something, let
UploadRecordingsrun. - Check CloudWatch Logs for the Lambda — you should see entries with
method=POST. - Check your S3 bucket — you should see objects named like:
4f4c3a71-6ad4-4aad-9d2a-f932b261a0a5_recordings.zip
That’s the file PCIbex uploaded.
8. Notes and variations
- You can change
ContentType="application/zip"if you know PCIbex is uploading another type. - You can create subfolders by changing the key to e.g.
f"pcibex/{uuid.uuid4()}_{filename}". - If you host PCIbex somewhere else, change
ALLOWED_ORIGINand the API Gateway CORS origin to match that origin.
Done
You now have a PCIbex experiment that records audio in the browser and ships it straight to your S3 bucket through a small, modern, Python AWS Lambda.