Raspberrypi Pico Webusb Performance Test
RaspberryPi Pico Google Chrome WebUSB Performance using Svelte
Suyash Singh
Posted by Suyash Singh
on March 19, 2024
Photo by Vishnu on Unsplash

I have a Raspberry Pi Pico, which I bought with the intent of learning how to program microcontrollers & USB peripherals. In the near future, I see myself using these skillsets to try my hand at developing my own Audio Interface. So, with the Pico at hand, I decided to delve into USB, particularly TinyUSB and WebUSB. This post shares my exploration and findings as I experimented with Raspberry Pi Pico’s USB capabilities, specifically focusing on its performance with WebUSB. Imagine a product that can be configured directly from a browser or has its firmware updated via the browser, all powered by the microcontroller like Raspberry Pi Pico.

In this blog, we are going to see Svelte in action to evaluate the performance of RaspberryPi Pico TinyUSB WebUSB in Google Chrome. I am writing this post with the intent of providing a skeleton framework for building your own WebUSB app. Feel free to modify bits of it as you see fit.

The performance test below evaluates transfer speeds for different combinations of chunkSize and queryTime parameters.

Understanding USB Modes

WebUSB

WebUSB is a relatively newer addition to USB standards that allows web applications to interact with USB devices directly, bypassing the need for native drivers. It simplifies the process of connecting USB devices to web applications, making it more accessible and user-friendly. WebUSB offers advantages like ease of use and platform independence, allowing devices like Raspberry Pi Pico to communicate seamlessly with web browsers.

USB 2.0 and USB 3.0

USB 2.0 and USB 3.0 are traditional USB standards. USB 2.0 offers data transfer rates of up to 480 Mbps, suitable for many peripherals and devices. USB 3.0, also known as SuperSpeed USB, enhances this capability with data transfer rates up to 5 Gbps, making it ideal for high-speed data transfers such as HD video streaming or large file transfers.

WebUSB performance test setup structure

Use the filetree to go to a file directly by clicking on it

src/components/TransferSpeed.svelte

src/components/TransferSpeed.svelte
<script lang="ts">
	import { maxSpeed, speedInKBps } from '@/stores/store';
 
	let speedVal: number;
	speedInKBps.subscribe((val) => {
		speedVal = val;
	});
	let maxSpeedVal: Maxspeed | null;
	maxSpeed.subscribe((val) => {
		maxSpeedVal = val;
	});
</script>
 
<div class="grow shrink-0 flex flex-col items-center justify-center">
	<div class="shrink-0 flex flex-row space-x-4 items-center justify-center mt-16">
		<div class="flex flex-col">
			<span class="font-boldt text-gray-600"> Max Speed </span>
			<span class="font-boldt text-gray-600"> Max Speed Query Time </span>
			<span class="font-boldt text-gray-600"> Max Speed Chunk Size </span>
		</div>
		<div class="flex flex-col">
			{#if maxSpeedVal}
				<span class="text-md">{maxSpeedVal.speed.toFixed(2)} KB/s</span>
				<span class="text-md">{maxSpeedVal.queryTime} ms</span>
				<span class="text-md">{(maxSpeedVal.chunkSizeInBytes / 1024.0).toFixed(2)} KB</span>
			{/if}
		</div>
	</div>
</div>
 

Here we are showing the current speed for the current chunkSizeInBytes and queryTime parameters.

src/routes/+layout.svelte

src/routes/+layout.svelte
<script>
	import '../routes/styles.css';
	export const ssr = false;
</script>
 
<div class="h-full w-full bg-black text-white">
	<main class="h-full w-full bg-black text-white">
		<slot />
	</main>
</div>
 

src/routes/+page.svelte

src/routes/+page.svelte
<script lang="ts">
	import AudioInterface from '@/utils/AudioInterface.svelte';
	import { isDeviceConnected, isDeviceConnecting } from '@/stores/store';
	import { connectToAudioInterface } from '@/utils/Audio';
	let deviceConnected = false;
	let deviceConnecting = false;
	isDeviceConnected.subscribe((val: boolean) => {
		deviceConnected = val;
	});
	isDeviceConnecting.subscribe((val: boolean) => {
		deviceConnecting = val;
	});
</script>
 
<svelte:head>
	<title>Home</title>
	<meta name="description" content="Svelte demo app" />
</svelte:head>
 
<div class="h-full w-full bg-black text-white">
	{#if !deviceConnected}
		<div
			class="bg-[#0A0A0A] h-full flex-col w-full px-64 flex space-y-8 items-center justify-center"
		>
			<div class="w-[300px] h-[300px] flex items-center justify-center">
				<div class="absolute relative flex justify-center items-center h-full w-full">
					{#if deviceConnecting}
						<div
							class="animate-spin rounded-full h-64 w-64 border-t-2 border-b-2 border-purple-900 opacity-100"
						></div>
					{/if}
					<img alt="raspberrypi logo" src="/raspberrypi.png" class="w-[256px] h-[143px] absolute" />
				</div>
			</div>
			<h1 class="text-4xl mb-6 font-bold">Connect to Raspberry Pi Pico</h1>
 
			<button
				class="flex w-64 h-16 flex-row items-center overflow-hidden rounded-2xl bg-white shadow-xl shadow-[#00007f] cursor-pointer"
				on:click={() => connectToAudioInterface()}
			>
				<div
					class="flex h-full w-full flex-row items-center justify-center space-x-4 rounded-2xl border-8 border-l-0 border-t-0 border-slate-300 py-6"
				>
					<span
						class="text-error-container-dark font-heading text-xl font-bold text-black select-none hover:text-[#0000FF]"
					>
						Connect
					</span>
				</div>
			</button>
		</div>
	{:else}
		<AudioInterface />
	{/if}
</div>
 

Here we are rendering the UI to connect to the RaspberryPi Pico if not already connected.

src/routes/+page.ts

src/routes/+page.ts
// since there's no dynamic data here, we can prerender
// it so that it gets served as a static asset in production
export const prerender = true;
 

src/routes/AudioInterface.svelte

src/routes/AudioInterface.svelte
<script lang="ts">
	import { onMount } from 'svelte';
	import TransferSpeed from '@/components/TransferSpeed.svelte';
	import {
		audioContext,
		accumulatedBuffer,
		chunkSizeInBytes,
		queryTimeInMs,
		maxSpeed,
		chunkSizes,
		queryTimes,
		performanceData
	} from '@/stores/store';
 
	let maxSpeedVal: Maxspeed | null;
	let performanceDataVal: Record<number, Record<number, PerformanceCell>> | null;
 
	maxSpeed.subscribe((v) => {
		maxSpeedVal = v;
	});
 
	performanceData.subscribe((v) => {
		performanceDataVal = v;
	});
 
	onMount(() => {
		$audioContext = new (window.AudioContext || window.webkitAudioContext)();
		$accumulatedBuffer = null;
 
		let currentIndex = 0;
		let prevChunkSize = 0;
		let prevQueryTime = 0;
 
		const executeFunction = (chunkSize: number, queryTime: number) => {
			chunkSizeInBytes.set(chunkSize);
			queryTimeInMs.set(queryTime);
		};
 
		const runWithDelay = () => {
			if (currentIndex >= chunkSizes.length * queryTimes.length) {
				return;
			}
 
			const currentChunkSize = chunkSizes[Math.floor(currentIndex / queryTimes.length)];
			const currentQueryTime = queryTimes[currentIndex % queryTimes.length];
 
			if (prevQueryTime !== 0 && prevChunkSize !== 0) {
				(performanceDataVal as Record<number, Record<number, PerformanceCell>>)[prevChunkSize][
					prevQueryTime
				].completed = true;
			}
			prevChunkSize = currentChunkSize;
			prevQueryTime = currentQueryTime;
			executeFunction(currentChunkSize, currentQueryTime);
 
			currentIndex++;
 
			setTimeout(() => {
				chunkSizeInBytes.set(0);
				queryTimeInMs.set(0);
			}, 6000);
			setTimeout(runWithDelay, 8000);
		};
 
		setTimeout(runWithDelay, 1000);
	});
</script>
 
<div
	class="h-full w-full flex flex-col relative items-center justify-between space-x-12 overflow-hidden"
>
	<span class="text-xl font-bold text-gray-400 mt-16">RaspberryPi Pico WebUSB Benchmark</span>
	<TransferSpeed />
	<div class="grow flex flex-row w-full relative items-center justify-center px-48">
		<div
			class="
				grid grid-cols-11 overflow-auto grow
			"
		>
			<div class="flex flex-col grow h-12 items-center justify-center">
				<span class="w-32 font-bold align-middle text-gray-600">QueryTime / ChunkSize</span>
			</div>
			{#each chunkSizes as chunkSize, j (j)}
				<div class="flex flex-col grow h-12 items-center justify-center">
					<span class="w-12 font-bold text-center align-middle text-gray-600"
						>{chunkSize < 1024 ? (chunkSize / 1.0).toFixed(0) : (chunkSize / 1024.0).toFixed(0)}
						{chunkSize < 1024 ? 'B' : 'KB'}</span
					>
				</div>
			{/each}
			{#each queryTimes as queryTime, i (i)}
				<span class="w-12 font-bold text-center align-middle text-gray-600">{queryTime} ms</span>
				{#each chunkSizes as chunkSize, j (j)}
					{#if performanceDataVal && maxSpeedVal && performanceDataVal[chunkSize][queryTime].maxSpeed}
						<div
							class:bg-yellow-100={performanceDataVal[chunkSize][queryTime].started &&
								!performanceDataVal[chunkSize][queryTime].completed}
							class:bg-green-800={performanceDataVal[chunkSize][queryTime].maxSpeed ===
								maxSpeedVal.speed}
							class:bg-green-600={performanceDataVal[chunkSize][queryTime].maxSpeed <
								maxSpeedVal.speed &&
								performanceDataVal[chunkSize][queryTime].maxSpeed >= 0.9 * maxSpeedVal.speed}
							class:bg-yellow-500={performanceDataVal[chunkSize][queryTime].maxSpeed <
								0.9 * maxSpeedVal.speed &&
								performanceDataVal[chunkSize][queryTime].maxSpeed >= 0.8 * maxSpeedVal.speed}
							class:bg-orange-500={performanceDataVal[chunkSize][queryTime].maxSpeed <
								0.8 * maxSpeedVal.speed &&
								performanceDataVal[chunkSize][queryTime].maxSpeed >= 0.7 * maxSpeedVal.speed}
							class:bg-orange-600={performanceDataVal[chunkSize][queryTime].maxSpeed <
								0.7 * maxSpeedVal.speed &&
								performanceDataVal[chunkSize][queryTime].maxSpeed >= 0.6 * maxSpeedVal.speed}
							class:bg-orange-700={performanceDataVal[chunkSize][queryTime].maxSpeed <
								0.6 * maxSpeedVal.speed &&
								performanceDataVal[chunkSize][queryTime].maxSpeed >= 0.5 * maxSpeedVal.speed}
							class:bg-orange-800={performanceDataVal[chunkSize][queryTime].maxSpeed <
								0.5 * maxSpeedVal.speed &&
								performanceDataVal[chunkSize][queryTime].maxSpeed >= 0.4 * maxSpeedVal.speed}
							class:bg-red-600={performanceDataVal[chunkSize][queryTime].maxSpeed <
								0.4 * maxSpeedVal.speed &&
								performanceDataVal[chunkSize][queryTime].maxSpeed >= 0.3 * maxSpeedVal.speed}
							class:bg-red-700={performanceDataVal[chunkSize][queryTime].maxSpeed <
								0.3 * maxSpeedVal.speed &&
								performanceDataVal[chunkSize][queryTime].maxSpeed >= 0.2 * maxSpeedVal.speed}
							class:bg-red-800={performanceDataVal[chunkSize][queryTime].maxSpeed <
								0.2 * maxSpeedVal.speed &&
								performanceDataVal[chunkSize][queryTime].maxSpeed >= 0.1 * maxSpeedVal.speed}
							class:bg-red-900={performanceDataVal[chunkSize][queryTime].maxSpeed <
								0.1 * maxSpeedVal.speed}
							class="flex flex-col grow h-12 items-center justify-center"
						>
							<span class="fontw-12 -bold text-center"
								>{performanceDataVal[chunkSize][queryTime].maxSpeed.toFixed(2)} KB/s</span
							>
						</div>
					{:else}
						<div class="flex flex-col grow h-12 items-center justify-center">
							<span class="w-12 font-bold text-center text-gray-600">-</span>
						</div>
					{/if}
				{/each}
			{/each}
		</div>
	</div>
</div>

Here we are rendering the tests for the transfer speeds periodically with different chunk sizes and query times, showing them in a tabular manner. There is definitely a massive scope for using an iterator for rendering each cell, instead of writing the dedicated HTML for it.

src/routes/styles.css

src/routes/styles.css
@import '@fontsource/fira-mono';
@tailwind base;
@tailwind components;
@tailwind utilities;
 

src/stores/store.ts

src/stores/store.ts
import { writable } from 'svelte/store';
 
export const isDeviceConnected = writable(false);
export const isDeviceConnecting = writable(false);
export const audioContext = writable<AudioContext | null>(null);
export const accumulatedBuffer = writable<AudioBuffer | null>(null);
export const speedInKBps = writable<number>(0);
export const chunkSizeInBytes = writable<number>(1024 * 12);
export const queryTimeInMs = writable<number>(10);
export const maxSpeed = writable<Maxspeed | null>(null);
export const chunkSizes = [
	0.5 * 1024,
	0.6 * 1024,
	0.7 * 1024,
	0.8 * 1024,
	0.9 * 1024,
	1 * 1024,
	2 * 1024,
	4 * 1024,
	8 * 1024,
	16 * 1024
];
export const queryTimes = [20, 30, 40, 50, 60];
let _performanceData: Record<number, Record<number, PerformanceCell>> = {};
 
chunkSizes.forEach((chunkSize) =>
	queryTimes.forEach((queryTime) => {
		if (!_performanceData[chunkSize]) {
			_performanceData[chunkSize] = {};
		}
 
		_performanceData[chunkSize][queryTime] = <PerformanceCell>{
			started: false,
			completed: false,
			maxSpeed: 0,
			currentSpeed: 0
		};
	})
);
 
export const performanceData = writable<typeof _performanceData>(_performanceData);
 

Any state important for our performance testing lives here.

src/utils/Audio.ts

src/utils/Audio.ts
import { throttle } from 'lodash';
import Serial from '@/utils/serial';
import {
	accumulatedBuffer,
	audioContext,
	chunkSizeInBytes,
	isDeviceConnected,
	isDeviceConnecting,
	queryTimeInMs
} from '@/stores/store';
 
const serial: Serial = new Serial(<USBDevice>{});
let port: Serial = <Serial>{};
const audioBuffers: AudioBuffer[] = Array(10000);
let currWriteIdx = 0;
let accBufferVal: AudioBuffer | null;
let audioContextVal: AudioContext | null;
let queryTimeInMsVal: number = 0;
let chunkSizeVal: number = 0;
let nextStartTime = 0;
let currBuffIdx = 0;
let readDataThrottled = throttle((chunkSize: number, queryTime: number) => {
	if (chunkSize && queryTime) {
		port.readData(chunkSize, queryTime);
	}
	readDataThrottled(chunkSizeVal, queryTimeInMsVal);
}, queryTimeInMsVal || 20);
 
accumulatedBuffer.subscribe((val) => {
	accBufferVal = val;
});
audioContext.subscribe((val) => {
	audioContextVal = val;
});
 
chunkSizeInBytes.subscribe((val) => (chunkSizeVal = val));
queryTimeInMs.subscribe((val) => {
	readDataThrottled = throttle((chunkSize: number, queryTime: number) => {
		if (chunkSize && queryTime) {
			port.readData(chunkSize, queryTime);
		}
		readDataThrottled(chunkSizeVal, queryTimeInMsVal);
	}, val || 20);
 
	queryTimeInMsVal = val;
});
 
export const connectToAudioInterface = () => {
	isDeviceConnecting.set(true);
	serial
		.requestPort()
		.then((selectedPort) => {
			port = selectedPort;
			initDevice();
		})
		.catch((error) => {
			isDeviceConnecting.set(false);
			console.error('Could not connect to Pico Audio interface', error);
		});
};
 
export const disconnectAudioInterface = () => {
	serial.disconnect();
};
 
const initDevice = () => {
	port
		.connect()
		.then(() => {
			isDeviceConnected.set(true);
			port.onReceive = (data) => {
				if (!data) return;
				addToBuffers(data);
				playAudio();
			};
			port.onReceiveError = (error) => {
				console.error('Error on receiving data from Pico Audio interface', error);
			};
			readDataThrottled(chunkSizeVal, queryTimeInMsVal);
		})
		.catch((error) => console.error('Could not connect to Pico Audio interface', error));
};
 
 
const addToBuffers = (audioData: DataView) => {
	const chunkSize = audioData.byteLength;
 
	if (!accBufferVal) {
		accumulatedBuffer.set((audioContextVal as AudioContext).createBuffer(1, chunkSize, 44100));
	}
 
	const channelData = (accBufferVal as AudioBuffer).getChannelData(0);
 
	for (let i = 0; i < chunkSize; ++i) {
		channelData[i] = audioData.getInt8(i) / 1280;
	}
 
	audioBuffers[currWriteIdx] = accBufferVal as AudioBuffer;
	currWriteIdx += 1;
	currWriteIdx = currWriteIdx % audioBuffers.length;
};
 
function playAudio() {
	const source = (audioContextVal as AudioContext).createBufferSource();
	source.buffer = audioBuffers[currBuffIdx];
	source.connect((audioContextVal as AudioContext).destination);
 
	nextStartTime += source.buffer.duration;
 
	source.start(nextStartTime);
 
	currBuffIdx += 1;
	currBuffIdx = currBuffIdx % audioBuffers.length;
}
 
  • Reads data periodically in a throttled manner
  • Plays audio from the data sent

src/utils/serial.ts

src/utils/serial.ts
import { throttle } from 'lodash';
import {
	chunkSizeInBytes,
	maxSpeed,
	performanceData,
	queryTimeInMs,
	speedInKBps
} from '@/stores/store';
 
export default class Serial {
	device: USBDevice | null = null;
	interfaceNumber: number = 0;
	endpointIn: number = 0;
	endpointOut: number = 0;
	startTime: Date = new Date();
	endTime: Date = new Date();
	performanceData: Record<number, Record<number, PerformanceCell>> | null = null;
	prevChunkSize = 0;
	prevQueryTime = 0;
	chunkSizeInBytes = 0;
	queryTimeInMs: number = 0;
	maxSpeed: Maxspeed | null = null;
 
	updateSpeed = throttle((ms: number, chunkSize: number, queryTime: number) => {
		if (this.queryTimeInMs === 0 || this.chunkSizeInBytes === 0) {
			speedInKBps.set(0);
			return;
		}
 
		const speed = (chunkSize * 1000.0) / (1024 * ms);
 
		if (speed > 1536) {
			return;
		}
 
		if (this.performanceData) {
			if (this.prevChunkSize === chunkSize && this.prevQueryTime === queryTime) {
				this.performanceData[chunkSize][queryTime].completed = false;
				this.performanceData[chunkSize][queryTime].started = true;
			} else if (this.prevChunkSize != 0 && this.prevChunkSize != 0) {
				this.performanceData[this.prevChunkSize][this.prevQueryTime].completed = true;
			}
			this.performanceData[chunkSize][queryTime].currentSpeed = speed;
 
			if (this.performanceData[chunkSize][queryTime].maxSpeed < speed) {
				this.performanceData[chunkSize][queryTime].maxSpeed = speed;
			}
			performanceData.set(this.performanceData);
		}
 
		if (!this.maxSpeed || this.maxSpeed.speed < speed) {
			maxSpeed.set({
				speed,
				queryTime: queryTime,
				chunkSizeInBytes: chunkSize
			});
		}
		speedInKBps.set(speed);
	}, 300);
 
	end(startTime: Date, endTime: Date, chunkSize: number, queryTime: number) {
		const timeDiff: number = endTime.getTime() - startTime.getTime(); //in ms
		const ms = Math.round(timeDiff);
 
		this.updateSpeed(ms, chunkSize, queryTime);
	}
 
	async getPorts(): Promise<Serial[]> {
		return navigator.usb.getDevices().then((devices) => {
			return devices.map((device) => new Serial(device));
		});
	}
 
	async requestPort(): Promise<Serial> {
		const filters = [{ vendorId: 0xcafe }]; // TinyUSB
		return navigator.usb.requestDevice({ filters: filters }).then((device) => {
			return new Serial(device);
		});
	}
 
	constructor(device: USBDevice) {
		this.device = device;
		chunkSizeInBytes.subscribe((v) => (this.chunkSizeInBytes = v));
		maxSpeed.subscribe((v) => (this.maxSpeed = v));
		queryTimeInMs.subscribe((v) => (this.queryTimeInMs = v));
		performanceData.subscribe((v) => (this.performanceData = v));
	}
 
	readData(chunkSize: number, queryTime: number) {
		const startTime = new Date();
		this.device
			?.transferIn(this.endpointIn, chunkSize)
			.then(
				(result) => {
					const endTime = new Date();
					this.end(startTime, endTime, chunkSize, queryTime);
					this.onReceive(result.data);
				},
				(error) => {
					this.onReceiveError(error);
				}
			)
			.catch((error) => {
				this.onReceiveError(error);
			});
	}
 
	async connect(): Promise<void> {
		return this.device
			?.open()
			.then(() => {
				if (this.device?.configuration === null) {
					return this.device.selectConfiguration(1);
				}
			})
			.then(() => {
				const interfaces = this.device?.configuration?.interfaces;
				interfaces?.forEach((element) => {
					element.alternates.forEach((elementalt) => {
						if (elementalt.interfaceClass == 0xff) {
							this.interfaceNumber = element.interfaceNumber;
							elementalt.endpoints.forEach((elementendpoint) => {
								if (elementendpoint.direction == 'out') {
									this.endpointOut = elementendpoint.endpointNumber;
								}
								if (elementendpoint.direction == 'in') {
									this.endpointIn = elementendpoint.endpointNumber;
								}
							});
						}
					});
				});
			})
			.then(() => this.device?.claimInterface(this.interfaceNumber))
			.then(() => this.device?.selectAlternateInterface(this.interfaceNumber, 0))
			.then(
				() =>
					this.device?.controlTransferOut({
						requestType: 'class',
						recipient: 'interface',
						request: 0x22,
						value: 0x01,
						index: this.interfaceNumber
					})
			)
			.then(() => {});
	}
 
	disconnect(): Promise<void> | undefined {
		return this.device
			?.controlTransferOut({
				requestType: 'class',
				recipient: 'interface',
				request: 0x22,
				value: 0x00,
				index: this.interfaceNumber
			})
			.then(() => this.device?.close());
	}
 
	send(data: Uint8Array): Promise<USBOutTransferResult> | undefined {
		return this.device?.transferOut(this.endpointOut, data);
	}
 
	onReceive(data: DataView | undefined): void {
		// Handle received data
	}
 
	onReceiveError(error: Error): void {
		// Handle receive error
	}
}
 

Here we are handling the incoming serialized data and also initialize the connection to the WebUSB compatible device

src/typings.d.ts

src/typings.d.ts
interface Maxspeed {
	speed: number;
	queryTime: number;
	chunkSizeInBytes: number;
}
 
interface PerformanceCell {
	currentSpeed: number;
	maxSpeed: number;
	started: boolean;
	completed: boolean;
}

I am hopeful that using this approach you can use a similar setup for your own hobby performance testing for WebUSB.