diff --git a/src/content/docs/workers-ai/tutorials/build-a-voice-notes-app-with-auto-transcription.mdx b/src/content/docs/workers-ai/tutorials/build-a-voice-notes-app-with-auto-transcription.mdx
new file mode 100644
index 000000000000000..0fd29093f87e7dd
--- /dev/null
+++ b/src/content/docs/workers-ai/tutorials/build-a-voice-notes-app-with-auto-transcription.mdx
@@ -0,0 +1,1180 @@
+---
+updated: 2024-11-09
+difficulty: Intermediate
+content_type: 📝 Tutorial
+pcx_content_type: tutorial
+title: Build a Voice Notes App with auto transcriptions using Workers AI
+products:
+ - Workers
+ - Workers AI
+ - D1
+ - R2
+tags:
+ - AI
+ - Nuxt
+languages:
+ - TypeScript
+description: Explore how you can use AI models to transcribe audio recordings and post process the transcriptions.
+spotlight:
+ author: Rajeev R. Sharma
+ author_bio_link: https://www.linkedin.com/in/rajeevrsharma
+ author_bio_source: LinkedIn
+---
+
+import { Render, PackageManagers, Tabs, TabItem } from "~/components";
+
+In this tutorial, you will learn how to create a Voice Notes App with automatic transcriptions of voice recordings, and optional post-processing. The following tools will be used to build the application:
+
+- Workers AI to transcribe the voice recordings, and for the optional post processing
+- D1 database to store the notes
+- R2 storage to store the voice recordings
+- Nuxt framework to build the full-stack application
+- Workers to deploy the project
+
+## Prerequisites
+
+To continue, you will need:
+
+
+
+## 1. Create a new Worker project
+
+Create a new Worker project using the `c3` CLI with the `nuxt` framework preset.
+
+
+
+### Install additional dependencies
+
+Change into the newly created project directory
+
+```sh
+cd voice-notes
+```
+
+And install the following dependencies:
+
+
+
+Then add the `@nuxt/ui` module to the `nuxt.config.ts` file:
+
+```ts title="nuxt.config.ts"
+export default defineNuxtConfig({
+ //..
+
+ modules: ['nitro-cloudflare-dev', '@nuxt/ui'],
+
+ //..
+})
+```
+
+### [Optional] Move to Nuxt 4 compatibility mode
+
+Moving to Nuxt 4 compatibility mode ensures that your application remains forward-compatible with upcoming updates to Nuxt.
+
+Create a new `app` folder in the project's root directory and move the `app.vue` file to it. Also, add the following to your `nuxt.config.ts` file:
+
+```ts title="nuxt.config.ts"
+export default defineNuxtConfig({
+ //..
+
+ future: {
+ compatibilityVersion: 4,
+ },
+
+ //..
+})
+```
+
+:::note
+The rest of the tutorial will use the `app` folder for keeping the client side code. If you did not make this change, you should continue to use the project's root directory.
+:::
+
+### Start local development server
+
+At this point you can test your application by starting a local development server using:
+
+
+
+If everything is set up correctly, you should see a Nuxt welcome page at `http://localhost:3000`.
+
+## 2. Create the transcribe API endpoint
+
+This API makes use of Workers AI to transcribe the voice recordings. To use Workers AI within your project, you first need to bind it to the Worker.
+
+
+
+Add the `AI` binding to the `wrangler.toml` file.
+
+```toml title="wrangler.toml"
+[ai]
+binding = "AI"
+```
+
+Once the `AI` binding has been configured, run the `cf-typegen` command to generate the necessary Cloudflare type definitions. This makes the types definitions available in the server event contexts.
+
+
+
+Create a transcribe `POST` endpoint by creating `transcribe.post.ts` file inside the `/server/api` directory.
+
+```ts title="server/api/transcribe.post.ts"
+export default defineEventHandler(async (event) => {
+ const { cloudflare } = event.context;
+
+ const form = await readFormData(event);
+ const blob = form.get('audio') as Blob;
+ if (!blob) {
+ throw createError({
+ statusCode: 400,
+ message: 'Missing audio blob to transcribe',
+ });
+ }
+
+ try {
+ const response = await cloudflare.env.AI.run('@cf/openai/whisper', {
+ audio: [...new Uint8Array(await blob.arrayBuffer())],
+ });
+
+ return response.text;
+ } catch (err) {
+ console.error('Error transcribing audio:', err);
+ throw createError({
+ statusCode: 500,
+ message: 'Failed to transcribe audio. Please try again.',
+ });
+ }
+});
+```
+
+The above code does the following:
+
+1. Extracts the audio blob from the event.
+2. Transcribes the blob using the `@cf/openai/whisper` model and returns the transcription text as response.
+
+## 3. Create an API endpoint for uploading audio recordings to R2
+
+Before uploading the audio recordings to `R2`, you need to create a bucket first. You will also need to add the R2 binding to your `wrangler.toml` file and regenerate the Cloudflare type definitions.
+
+Create an `R2` bucket.
+
+
+
+
+```sh
+npx wrangler r2 bucket create
+```
+
+
+
+
+```sh
+yarn dlx wrangler r2 bucket create
+```
+
+
+
+
+```sh
+pnpm dlx wrangler r2 bucket create
+```
+
+
+
+
+Add the storage binding to your `wrangler.toml` file.
+
+```toml title="wrangler.toml"
+[[r2_buckets]]
+binding = "R2"
+bucket_name = ""
+```
+
+Finally, generate the type definitions by rerunning the `cf-typegen` script.
+
+Now you are ready to create the upload endpoint. Create a new `upload.put.ts` file in your `server/api` directory, and add the following code to it:
+
+```ts title="server/api/upload.put.ts"
+export default defineEventHandler(async (event) => {
+ const { cloudflare } = event.context;
+
+ const form = await readFormData(event);
+ const files = form.getAll('files') as File[];
+ if (!files) {
+ throw createError({ statusCode: 400, message: 'Missing files' });
+ }
+
+ const uploadKeys: string[] = [];
+ for (const file of files) {
+ const obj = await cloudflare.env.R2.put(`recordings/${file.name}`, file);
+ if (obj) {
+ uploadKeys.push(obj.key);
+ }
+ }
+
+ return uploadKeys;
+});
+```
+
+The above code does the following:
+
+1. The files variable retrieves all files sent by the client using form.getAll(), which allows for multiple uploads in a single request.
+2. Uploads the files to the R2 bucket using the binding (`R2`) you created earlier.
+
+:::note
+The `recordings/` prefix organizes uploaded files within a dedicated folder in your bucket. This will also come in handy when serving these recordings to the client (covered later).
+:::
+
+## 4. Create an API endpoint to save notes entries
+
+Before creating the endpoint, you will need to perform steps similar to those for the R2 bucket, with some additional steps to prepare a notes table.
+
+Create a `D1` database.
+
+
+
+
+```sh
+npx wrangler d1 create
+```
+
+
+
+
+```sh
+yarn dlx wrangler d1 create
+```
+
+
+
+
+```sh
+pnpm dlx wrangler d1 create
+```
+
+
+
+
+Add the D1 bindings to the `wrangler.toml` file. You can get the `DB_ID` from the output of the `d1 create` command.
+
+```toml title="wrangler.toml"
+[[d1_databases]]
+binding = "DB"
+database_name = ""
+database_id = ""
+```
+
+As before, rerun the `cf-typegen` command to generate the types.
+
+Next, create a DB migration.
+
+
+
+
+```sh
+npx wrangler d1 migrations create "create notes table"
+```
+
+
+
+
+```sh
+yarn dlx wrangler d1 migrations create "create notes table"
+```
+
+
+
+
+```sh
+pnpm dlx wrangler d1 migrations create "create notes table"
+```
+
+
+
+
+This will create a new `migrations` folder in the project's root directory, and add an empty `0001_create_notes_table.sql` file to it. Replace the contents of this file with the code below.
+
+```sql
+CREATE TABLE IF NOT EXISTS notes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ text TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ audio_urls TEXT
+);
+```
+
+And then apply this migration to create the `notes` table.
+
+
+
+
+```sh
+npx wrangler d1 migrations apply
+```
+
+
+
+
+```sh
+yarn dlx wrangler d1 migrations apply
+```
+
+
+
+
+```sh
+pnpm dlx wrangler d1 migrations apply
+```
+
+
+
+
+:::note
+The above command will create the notes table locally. To apply the migration on your remote production database, use the `--remote` flag.
+:::
+
+Now you can create the API endpoint. Create a new file `index.post.ts` in the `server/api/notes` directory, and change its content to the following:
+
+```ts title="server/api/notes/index.post.ts"
+export default defineEventHandler(async (event) => {
+ const { cloudflare } = event.context;
+
+ const { text, audioUrls } = await readBody(event);
+ if (!text) {
+ throw createError({
+ statusCode: 400,
+ message: 'Missing note text',
+ });
+ }
+
+ try {
+ await cloudflare.env.DB.prepare(
+ 'INSERT INTO notes (text, audio_urls) VALUES (?1, ?2)'
+ )
+ .bind(text, audioUrls ? JSON.stringify(audioUrls) : null)
+ .run();
+
+ return setResponseStatus(event, 201);
+ } catch (err) {
+ console.error('Error creating note:', err);
+ throw createError({
+ statusCode: 500,
+ message: 'Failed to create note. Please try again.',
+ });
+ }
+});
+```
+
+The above does the following:
+
+1. Extracts the text, and optional audioUrls from the event.
+2. Saves it to the database after converting the audioUrls to a `JSON` string.
+
+## 5. Handle note creation on the client-side
+
+Now you're ready to work on the client side. Let's start by tackling the note creation part first.
+
+### Recording user audio
+
+Create a composable to handle audio recording using the MediaRecorder API. This will be used to record notes through the user's microphone.
+
+Create a new file `useMediaRecorder.ts` in the `app/composables` folder, and add the following code to it:
+
+```ts title="app/composables/useMediaRecorder.ts"
+interface MediaRecorderState {
+ isRecording: boolean;
+ recordingDuration: number;
+ audioData: Uint8Array | null;
+ updateTrigger: number;
+}
+
+export function useMediaRecorder() {
+ const state = ref({
+ isRecording: false,
+ recordingDuration: 0,
+ audioData: null,
+ updateTrigger: 0,
+ });
+
+ let mediaRecorder: MediaRecorder | null = null;
+ let audioContext: AudioContext | null = null;
+ let analyser: AnalyserNode | null = null;
+ let animationFrame: number | null = null;
+ let audioChunks: Blob[] | undefined = undefined;
+
+ const updateAudioData = () => {
+ if (!analyser || !state.value.isRecording || !state.value.audioData) {
+ if (animationFrame) {
+ cancelAnimationFrame(animationFrame);
+ animationFrame = null;
+ }
+
+ return;
+ }
+
+ analyser.getByteTimeDomainData(state.value.audioData);
+ state.value.updateTrigger += 1;
+ animationFrame = requestAnimationFrame(updateAudioData);
+ };
+
+ const startRecording = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+
+ audioContext = new AudioContext();
+ analyser = audioContext.createAnalyser();
+
+ const source = audioContext.createMediaStreamSource(stream);
+ source.connect(analyser);
+
+ mediaRecorder = new MediaRecorder(stream);
+ audioChunks = [];
+
+ mediaRecorder.ondataavailable = (e: BlobEvent) => {
+ audioChunks?.push(e.data);
+ state.value.recordingDuration += 1;
+ };
+
+ state.value.audioData = new Uint8Array(analyser.frequencyBinCount);
+ state.value.isRecording = true;
+ state.value.recordingDuration = 0;
+ state.value.updateTrigger = 0;
+ mediaRecorder.start(1000);
+
+ updateAudioData();
+ } catch (err) {
+ console.error('Error accessing microphone:', err);
+ throw err;
+ }
+ };
+
+ const stopRecording = async () => {
+ return await new Promise((resolve) => {
+ if (mediaRecorder && state.value.isRecording) {
+ mediaRecorder.onstop = () => {
+ const blob = new Blob(audioChunks, { type: 'audio/webm' });
+ audioChunks = undefined;
+
+ state.value.recordingDuration = 0;
+ state.value.updateTrigger = 0;
+ state.value.audioData = null;
+
+ resolve(blob);
+ };
+
+ state.value.isRecording = false;
+ mediaRecorder.stop();
+ mediaRecorder.stream.getTracks().forEach((track) => track.stop());
+
+ if (animationFrame) {
+ cancelAnimationFrame(animationFrame);
+ animationFrame = null;
+ }
+
+ audioContext?.close();
+ audioContext = null;
+ }
+ });
+ };
+
+ onUnmounted(() => {
+ stopRecording();
+ });
+
+ return {
+ state: readonly(state),
+ startRecording,
+ stopRecording,
+ };
+}
+```
+
+The above code does the following:
+
+1. Exposes functions to start and stop audio recordings in a Vue application.
+2. Captures audio input from the user's microphone using MediaRecorder API.
+3. Processes real-time audio data for visualization using AudioContext and AnalyserNode.
+4. Stores recording state including duration and recording status.
+5. Maintains chunks of audio data and combines them into a final audio blob when recording stops.
+6. Updates audio visualization data continuously using animation frames while recording.
+7. Automatically cleans up all audio resources when recording stops or component unmounts.
+8. Returns audio recordings in webm format for further processing.
+
+### Create a component for note creation
+
+This component allows users to create notes by either typing or recording audio. It also handles audio transcription and uploading the recordings to the server.
+
+Create a new file named `CreateNote.vue` inside the `app/components` folder. Add the following template code to the newly created file:
+
+```vue title="app/components/CreateNote.vue"
+
+
+
+
+
+
+ Note transcript
+
+
+
+
+
+
+
+
+ Note recordings
+
+
+
+
+
+
+
+
+
+
+
+ Transcribing...
+
+
+
+
+
+ No recordings...
+
+
+
+
+
+
+
+
+ Clear
+
+
+ Save
+
+
+
+
+```
+
+The above template results in the following:
+
+1. A panel with a `textarea` inside to type the note manually.
+2. Another panel to manage start/stop of an audio recording, and show the recordings done already.
+3. A bottom panel to reset or save the note (along with the recordings).
+
+Now, add the following code below the template code in the same file:
+
+```vue title="app/components/CreateNote.vue"
+
+```
+
+The above code does the following:
+
+1. When a recording is stopped by calling `handleRecordingStop` function, the audio blob is sent for transcribing to the transcribe API endpoint.
+2. The transcription response text is appended to the existing textarea content.
+3. When the note is saved by calling the `saveNote` function, the audio recordings are uploaded first to R2 by using the upload endpoint we created earlier. Then, the actual note content along with the audioUrls (the R2 object keys) are saved by calling the notes post endpoint.
+
+### Create a new page route for showing the component
+
+You can use this component in a Nuxt page to show it to the user. But before that you need to modify your `app.vue` file. Update the content of your `app.vue` to the following:
+
+```vue title="/app/app.vue"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ New Note
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+The above code allows for a nuxt page to be shown to the user, apart from showing an app header and a navigation sidebar.
+
+Next, add a new file named `new.vue` inside the `app/pages` folder, add the following code to it:
+
+```vue title="app/pages/new.vue"
+
+
+
+
+
Create note
+
+
+
+
+
+
+
+
+
+```
+
+The above code shows the `CreateNote` component inside a modal, and navigates back to the home page on successful note creation.
+
+## 6. Showing the notes on the client side
+
+To show the notes from the database on the client side, create an API endpoint first that will interact with the database.
+
+### Create an API endpoint to fetch notes from the database
+
+Create a new file named `index.get.ts` inside the `server/api/notes` directory, and add the following code to it:
+
+```ts title="server/api/index.get.ts"
+import type { Note } from '~~/types';
+
+export default defineEventHandler(async (event) => {
+ const { cloudflare } = event.context;
+
+ const res = await cloudflare.env.DB.prepare(
+ `SELECT
+ id,
+ text,
+ audio_urls AS audioUrls,
+ created_at AS createdAt,
+ updated_at AS updatedAt
+ FROM notes
+ ORDER BY created_at DESC
+ LIMIT 50;`
+ ).all & { audioUrls: string | null }>();
+
+ return res.results.map((note) => ({
+ ...note,
+ audioUrls: note.audioUrls ? JSON.parse(note.audioUrls) : undefined,
+ }));
+});
+```
+
+The above code fetches the last 50 notes from the database, ordered by their creation date in descending order. The `audio_urls` field is stored as a string in the database, but it's converted to an array using `JSON.parse` to handle multiple audio files seamlessly on the client side.
+
+Next, create a page named `index.vue` inside the `app/pages` directory. This will be the home page of the application. Add the following code to it:
+
+```vue title="app/pages/index.vue"
+
+
+
+
+
+
+
No notes created
+
Get started by creating your first note
+
+
+
+
+
+```
+
+The above code fetches the notes from the database by calling the `/api/notes` endpoint you created just now, and renders them as note cards.
+
+### Serving the saved recordings from R2
+
+To be able to play the audio recordings of these notes, you need to serve the saved recordings from the R2 storage.
+
+Create a new file named `[...pathname].get.ts` inside the `server/routes/recordings` directory, and add the following code to it:
+
+:::note
+The `...` prefix in the file name makes it a catch all route. This allows it to receive all events that are meant for paths starting with `/recordings` prefix. This is where the `recordings` prefix that was added previously while saving the recordings becomes helpful.
+:::
+
+```ts title="server/routes/recordings/[...pathname].get.ts"
+export default defineEventHandler(async (event) => {
+ const { cloudflare, params } = event.context;
+
+ const { pathname } = params || {};
+
+ return cloudflare.env.R2.get(`recordings/${pathname}`);
+});
+```
+
+The above code extracts the path name from the event params, and serves the saved recording matching that object key from the R2 bucket.
+
+## 7. [Optional] Post Processing the transcriptions
+
+Even though the speech-to-text transcriptions models perform satisfactorily, sometimes you want to post process the transcriptions for various reasons. It could be to remove any discrepancy, or to change the tone/style of the final text.
+
+### Create a settings page
+
+Create a new file named `settings.vue` in the `app/pages` folder, and add the following code to it:
+
+```vue title="app/pages/settings.vue"
+
+
+
+
+
+ Post Processing
+
+
+ Configure post-processing of recording transcriptions with AI models.
+
+
+ Settings changes are auto-saved locally.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+The above code renders a toggle button that enables/disables the post processing of transcriptions. If enabled, users can change the prompt that will used while post processing the transcription with an AI model.
+
+The transcription settings are saved using useStorageAsync, which utilizes the browser's local storage. This ensures that users' preferences are retained even after refreshing the page.
+
+### Send the post processing prompt with recorded audio
+
+Modify the `CreateNote` component to send the post processing prompt along with the audio blob, while calling the `transcribe` API endpoint.
+
+```vue title="app/components/CreateNote.vue" ins={2, 6-9, 17-22}
+
+```
+
+The code blocks added above checks for the saved post processing setting. If enabled, and there is a defined prompt, it sends the prompt to the `transcribe` API endpoint.
+
+### Handle post processing in the transcribe API endpoint
+
+Modify the transcribe API endpoint, and update it to the following:
+
+```ts title="server/api/transcribe.post.ts" ins={9-20, 22}
+export default defineEventHandler(async (event) => {
+ // ...
+
+ try {
+ const response = await cloudflare.env.AI.run('@cf/openai/whisper', {
+ audio: [...new Uint8Array(await blob.arrayBuffer())],
+ });
+
+ const postProcessingPrompt = form.get('prompt') as string;
+ if (postProcessingPrompt && response.text) {
+ const postProcessResult = await cloudflare.env.AI.run(
+ '@cf/meta/llama-3.1-8b-instruct',
+ {
+ temperature: 0.3,
+ prompt: `${postProcessingPrompt}.\n\nText:\n\n${response.text}\n\nResponse:`,
+ }
+ );
+
+ return (postProcessResult as { response?: string }).response;
+ } else {
+ return response.text;
+ }
+ } catch (err) {
+ // ...
+ }
+});
+```
+
+The above code does the following:
+
+1. Extracts the post processing prompt from the event FormData.
+2. If present, it calls the Workers AI API to process the transcription text using the `@cf/meta/llama-3.1-8b-instruct` model.
+3. Finally, it returns the response from Workers AI to the client.
+
+## 8. Deploy the application
+
+Now you are ready to deploy the project to a `.workers.dev` sub-domain by running the deploy command.
+
+
+
+You can preview your application at `..workers.dev`.
+
+:::note
+If you used `pnpm` as your package manager, you may face build errors like `"stdin" is not exported by "node_modules/.pnpm/unenv@1.10.0/node_modules/unenv/runtime/node/process/index.mjs"`. To resolve it, you can try hoisting your node modules with the [`shamefully-hoist-true`](https://pnpm.io/npmrc) option.
+:::
+
+## Conclusion
+
+In this tutorial, you have gone through the steps of building a voice notes application using Nuxt 3, Cloudflare Workers, D1, and R2 storage. You learnt to:
+
+- Set up the backend to store and manage notes
+- Create API endpoints to fetch and display notes
+- Handle audio recordings
+- Implement optional post-processing for transcriptions
+- Deploy the application using the Cloudflare module syntax
+
+The complete source code of the project is available on GitHub. You can go through it to see the code for various frontend components not covered in the article. You can find it here: [github.com/ra-jeev/vnotes](https://github.com/ra-jeev/vnotes).