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
<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
<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
<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
// 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
<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
@import '@fontsource/fira-mono';
@tailwind base;
@tailwind components;
@tailwind utilities;
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
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
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
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.