-->

dev gcp, speech-to-text, voice, aspnetcore, twilio

This article was originally published on the Twilio Blog.

Twilio Media Streams give programmers access to the raw audio of a phone call in real-time. This allows you to process the media and enhance your applications by running sentiment analysis, speech recognition, etc. In this tutorial, you will learn how to receive the raw audio via WebSockets, transcribe the call using Google Cloud’s Speech-to-Text service and play audio files based on the user’s commands.

Prerequisites

You’ll need the following things in this tutorial:

Set up GCP Speech-to-Text

To use the Speech-to-Text API, you must enable it in the Google Cloud console. If you have never used GCP, you can log in to your Google account and go to the free trial start page and

click the Start free button.

You will be asked to enter your personal information through a 2-step process. Once you’ve completed the process, you should gain access to $300 free credits that will be valid for 90 days.

To start developing your application, you will need to create a project in Google Cloud. In my account, Google automatically created a new project called “My First Project”. If you don’t have this or would like to create a brand new one, go to Menu > IAM & Admin > Create a Project.

You should see the new project creation screen with a default name already chosen for you. You can change it to your liking:

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 1

Click the Create button to finish project creation. To switch between the projects, you can click on the project name next to Google Cloud logo and browse your projects.

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 2

The dialog also has a New Project button which you can use to create new projects.

Once you’ve created and selected your project, go to the Cloud Speech-to-Text API product page and click Enable.

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 3

You should see a notification advising you to create credentials. Click Create Credentials.

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 4

In the Which API you are using section, select Cloud Speech-to-Text API if it’s not already selected.

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 5

In the What data will you be accessing section, select Application data.

Select No, I’m not using them as the answer to the Are you planning to use this API with Compute Engine… question and click Next.

On the Service account details page, give your service account a name such as transcribe-twilio-call.

The Service Account ID should be automatically populated based on the name you chose.

Click Create and Continue.

The rest of the settings are optional, so you can click Done and complete the process.

To use this service account, you will need credentials. On the left menu, click Credentials.

While still on the Cloud Speech-to-Text API page, switch to the Credentials tab.

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 6

Scroll down to the Service Accounts and click your account.

Switch to the Keys section and click Add Key → Create new key.

Select JSON if not selected already, and click Create.

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 7

This should start a download of your private key in a JSON file.

Copy this file to a safe location and set the GOOGLE_APPLICATION_CREDENTIALS environment variable by running the command appropriate to your system.

export GOOGLE_APPLICATION_CREDENTIALS={ PATH TO YOUR JSON FILE }
$Env:GOOGLE_APPLICATION_CREDENTIALS={ PATH TO YOUR JSON FILE }
set GOOGLE_APPLICATION_CREDENTIALS={ PATH TO YOUR JSON FILE }

Twilio Media Streams

In a Twilio voice application, you can use the Stream verb to receive raw audio streams from a live phone call over WebSockets in near real-time.

WebSockets

A WebSocket is a protocol for bidirectional communication between a client (such as a web browser) and a server over a single, long-lived connection. WebSockets allow for real-time, two-way communication between the client and server and can be used for a variety of applications such as online gaming, chat applications, and data streaming. Unlike traditional HTTP connections, which are request-response based, WebSockets provide a full-duplex communication channel for continuous, real-time data exchange.

Stream WebSocket Messages

In the Twilio Stream WebSocket, each message is sent in a JSON string. There are different message types, and to identify the message type, first, you need to parse the JSON and check the value of the event field.

The possible values for the WebSocket messages coming from Twilio are:

  • connected: The first message sent once a WebSocket connection is established.

  • start: This message contains important metadata about the stream and is sent immediately after the connected message. It is only sent once at the start of the Stream.

  • media: This message type encapsulates the raw audio data.

  • stop: This message will be sent when the Stream is stopped or the call has ended.

  • mark: The mark event is sent only during bidirectional streaming using the <Connect> verb. It is used to track or label when media has completed.

The possible values for the WebSocket messages coming from Twilio are:

  • media: To send media back to Twilio, you must provide a similarly formatted media message. The payload must be encoded audio/x-mulaw with a sample rate of 8000 and base64 encoded. The audio can be of any size.

  • mark: Send a mark event message after sending a media event message to be notified when the audio that you have sent has been completed.

  • clear: Send the clear event message if you would like to interrupt the audio that has been sent various media event messages.

In the demo project, you will learn more about the other fields that are used in these messages.

WAVE File Format Analysis

The telephony standard for audio is 8-bit PCM mono uLaw (MULAW) with a sampling rate of 8Khz. The payload of the media message should not contain the audio file type header bytes. So it’s essential to understand the WAV file header fields so that you can strip them off before sending the audio data to the user.

A standard WAV file header comprises the following fields:

Positions

Sample Value

Description

1 - 4

“RIFF”

Marks the file as a riff file. Characters are each 1 byte long.

5 - 8

File size (integer)

Size of the overall file - 8 bytes, in bytes (32-bit integer). Typically, you’d fill this in after creation.

9 -12

“WAVE”

File Type Header. For our purposes, it always equals “WAVE”.

13-16

“fmt “

Format chunk marker. Includes trailing null

17-20

16

Length of format data as listed above

21-22

1

Type of format (1 is PCM) - 2 byte integer

23-24

2

Number of Channels - 2 byte integer

25-28

44100

Sample Rate - 32 byte integer. Common values are 44100 (CD), 48000 (DAT). Sample Rate = Number of Samples per second, or Hertz.

29-32

176400

(Sample Rate * BitsPerSample * Channels) / 8.

33-34

4

(BitsPerSample * Channels) / 8.1 - 8 bit mono2 - 8 bit stereo/16 bit mono4 - 16 bit stereo

35-36

16

Bits per sample

37-40

“data”

“data” chunk header. Marks the beginning of the data section.

41-44

File size (data)

Size of the data section.

(Source: https://docs.fileformat.com/audio/wav/)

A WAVE file is a collection of a number of different types of chunks. The fmt chunk is required, and it contains parameters describing the waveform.

Now, open the bird.wav in a hex editor and review the file. Note that the file length is 54,084 bytes.

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 8

Here you can see the fields:

Positions

Bytes

Value

Explanation

1 - 4

52 49 46 46

“RIFF”

As expected

5 - 8

00 00 D3 3C

54,076 (Little-endian) (File length -8)

As expected

9 - 12

57 41 56 45

“WAVE”

As expected

13 - 16

66 6D 74 20

“fmt” with trailing space

As expected

17- 20

12 00 00 00

Length of fmt chunk data: 18

As expected. Can be 16, 18 or 40

21 - 22

00 07

Type of format: Mulaw (7)

As expected

23 - 24

00 01

Number of channels: 1

As expected

25 - 28

00 00 1F 40

Sample rate: 8000

As expected

29 - 32

00 00 1F 40

(Sample Rate * Bit per sample * Channels) / 8

(8000 * 8 * 1) / 8 = 8000

As expected

33 - 34

00 01

(BitsPerSample * Channels) / 8

(8 * 1) / 8 = 1

As expected

35 - 36

00 08

8 Bits per sample

As expected

37 - 38

00 00

Size of the extension: 0

Expected: Start of data. Microsoft Windows Media Player will not play non-PCM data (e.g. µ-law data) if the fmt chunk does not have the extension size field (cbSize) or a fact chunk is not present.

39 - 42

66 61 63 74

“fact”

Optional fact chunk

43 - 46

00 00 00 04

4 = Size of the fact chunk data

47 - 50

00 00 D3 0A

54,026 = chunk data. Equal to file length.

Fact chunk explanation

51 - 54

64 61 74 61

“data”

As expected, except it starts at 51 because of the fact chunk

55 - 58

00 00 D3 0A

Size of the data: 54,026

As expected

As you can see, the actual file header diverges slightly from the standard header description.

The takeaways from this analysis are:

  • The audio data starts after the first 58 bytes. You will skip those bytes in the demo and only send the audio data to the caller.

  • You may encounter different header lengths and subsequently may need to adjust the number of bytes to skip; otherwise, you may hear distorted audio on the phone.

Now that you understand the WAV format better, proceed to the next section to implement the project to play audio files to a phone call.

Sample Project: Animal Soundboard

The project requires to have some audio files to function properly. The easiest way to set up the starter project is by cloning the sample GitHub repository.

Open a terminal, change to the directory you want to download the project, and run the following command:

git clone https://github.com/Dev-Power/play-audio-to-a-phone-call-using-media-streams.git --branch starter-project

The project can be found in the src\PlayAudioUsingMediaStreams subfolder. Open the project in your IDE.

The starter project comes with 2 controllers: IncomingCallController and AnimalSoundboardController. IncomingCallController currently only plays back a simple message to test your setup. You will implement AnimalSoundboardController as you go along.

It also comes with 4 WAV files that will be used in the project.

Open another terminal and run ngrok like this:

ngrok http http://localhost:5214 

For Twilio to know where to send webhook requests, you need to update the webhook settings on your Twilio phone number.

Go to the Twilio Console. Select your account, and then click Phone Numbers → Manage → Active Numbers on the left pane. (If Phone Numbers isn’t on the left pane, click on Explore Products and then on Phone Numbers.)

Click on the phone number you want to use for your project and scroll down to the Voice section.

Under the A Call Comes In label, set the dropdown to Webhook, the text field next to it to the ngrok Forwarding URL suffixed with the /IncomingCall path, the next dropdown to HTTP POST, and click Save. It should look like this:

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 9

Note that you have to use HTTPS as the protocol when setting the webhook URL.

In the terminal, run the following command:

dotnet run

Call your Twilio number, and you should hear the message “If you can hear this, your setup works!” played back to you.

After you’ve confirmed you can receive calls in your application, update the code in the Index method of the IncomingCallController with the code below:

var response = new VoiceResponse();
response.Say("Say animal names to hear their sounds.");
var connect = new Connect();
connect.Stream(
    name: "Animal Soundboard", 
    url: Url.Action(
        action: "Get", 
        controller: "AnimalSoundboard",
        values: null,
        protocol: "wss"
    )
);
response.Append(connect);
Console.WriteLine(response.ToString());
return TwiML(response);

This update replaces the message and adds Stream verb to the output. It prints the response before sending it back, so you can see the TwiML you created, which looks like this:

<?xml version="1.0" encoding="utf-8"?>
<Response>
  <Say>Say animal names to hear their sounds.</Say>
  <Connect>
    <Stream name="Animal Soundboard" url="wss://{YOUR NGROK URL}/animalsoundboard"></Stream>
  </Connect>
</Response>

In the demo, you will receive raw user audio and play animal sounds back depending on the commands you receive, so you have to maintain a synchronous bi-directional connection. This is why you use the Connect verb instead of the Start verb, which is asynchronous and immediately continues with the next TwiML instruction. You can read more about TwiML stream verbs here.

Now, it’s time to implement the web socket. The first version will just echo the user’s voice back. Update the AnimalSoundboardController with the code below:

using System.Net.WebSockets;
using Microsoft.AspNetCore.Mvc;
using Twilio.AspNet.Core;
namespace PlayAudioUsingMediaStreams.WebApi.Controllers;
[ApiController]
[Route("[controller]")]
public class AnimalSoundboardController : Controller
{
    public async Task Get()
    {
        if (HttpContext.WebSockets.IsWebSocketRequest)
        {
            using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
            await Soundboard(webSocket);
        }
        else
        {
            HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
        }
    }
    private async Task Soundboard(WebSocket webSocket)
    {
        var buffer = new byte[1024 * 4];
        var receiveResult = await webSocket.ReceiveAsync(
            new ArraySegment<byte>(buffer), CancellationToken.None);
        while (!receiveResult.CloseStatus.HasValue)
        {
            await webSocket.SendAsync(
                new ArraySegment<byte>(buffer, 0, receiveResult.Count),
                receiveResult.MessageType,
                receiveResult.EndOfMessage,
                CancellationToken.None);
            receiveResult = await webSocket.ReceiveAsync(
                new ArraySegment<byte>(buffer), CancellationToken.None);
        }
        await webSocket.CloseAsync(
            receiveResult.CloseStatus.Value,
            receiveResult.CloseStatusDescription,
            CancellationToken.None);
    }
}

Before you run the application, you have to modify the Program.cs and add WebSocket support as shown below:

```csharp hl_lines=”3” app.MapControllers(); app.UseWebSockets(); app.Run();


Re-run the application and call your Twilio phone number again. You should hear yourself on the phone as you speak.

This version of the code reads the streaming data from the web socket, and as long as the connection is open, it sends the same data back to the user.

This is how you can access the raw audio of a phone call. This primitive version does not look inside the messages. What you receive over the web socket is a JSON message. 

In the next version, you will parse the messages as well. Before that, you'll need some supporting services. 

To model the sounds, create a new file called Sound.cs and update its contents like this:

```csharp
namespace PlayAudioUsingMediaStreams.WebApi;
public class Sound
{
    public string Name { get; set; }
    public List<string> Keywords { get; set; }
    public string AudioDataAsBase64 { get; set; }
}

Every sound has a name, a list of keywords, and the audio data. In this project, you will use animal names as keywords, but the application can be used for any group of sounds.

Create a new folder called Services and a new file under it called SoundService.cs. Update the code as below:

namespace PlayAudioUsingMediaStreams.WebApi.Services;
public class SoundService
{
    private const string AudioRoot = "../../audio";
    private const int WavHeaderBytesToSkip = 58;
    private List<Sound> _sounds = new()
    {
        new() { Name = "dog", Keywords = new List<string> { "dog", "canine", "pooch", "hound" } },
        new() { Name = "cat", Keywords = new List<string> { "cat", "kitty", "kitten" } },
        new() { Name = "bird", Keywords = new List<string> { "bird" } },
        new() { Name = "elephant", Keywords = new List<string> { "elephant" } },
    };
    public SoundService()
    {
        // Load all files into memory once to avoid constant disk access
        foreach (var sound in _sounds)
        {
            var audioFilePath = $"{AudioRoot}/{sound.Name}.wav";
            var rawAudioData = File.ReadAllBytes(audioFilePath);
            // Skip the header bytes while copying
            var tempAudioData = new byte[rawAudioData.Length - WavHeaderBytesToSkip];
            Array.Copy(rawAudioData, WavHeaderBytesToSkip, tempAudioData, 0, tempAudioData.Length);
            sound.AudioDataAsBase64 = Convert.ToBase64String(tempAudioData);
        }
    }
    public bool TryFindSoundByKeyword(string keyword, out Sound sound)
    {
        sound = _sounds.FirstOrDefault(s => s.Keywords.Contains(keyword));
        return sound != null;
    }
}

At construction, the service initializes all the sound objects. It loads the audio data into memory, so they can be played in rapid succession without having to access the files from disk repeatedly.

Also, it handles skipping the wav header bytes, as discussed in the previous section.

You also need to identify the keywords that the user is uttering. To achieve this, you’ll need to use Google Speech-to-Text service. Install the SDK by running the following command:

dotnet add package Google.Cloud.Speech.V1

Under the Services folder, create a new file called SpeechRecognitionService.cs with the following contents:

using Google.Api.Gax.Grpc;
using Google.Cloud.Speech.V1;
using Google.Protobuf;
namespace PlayAudioUsingMediaStreams.WebApi.Services;
public class SpeechRecognitionService
{
    private StreamingRecognitionConfig _streamingConfig = new()
    {
        Config = new RecognitionConfig
        {
            Encoding = RecognitionConfig.Types.AudioEncoding.Mulaw,
            SampleRateHertz = 8000,
            LanguageCode = "en-US",
            EnableWordConfidence = true,
            UseEnhanced = true
        },
        InterimResults = true
    };
    private SpeechClient _speechClient;
    private SpeechClient.StreamingRecognizeStream _streamingRecognizeStream;
    public SpeechRecognitionService(SpeechClient speechClient)
    {
        _speechClient = speechClient;
    }
    public async Task<AsyncResponseStream<StreamingRecognizeResponse>> InitStream()
    {
        _streamingRecognizeStream = _speechClient.StreamingRecognize();
        await _streamingRecognizeStream.WriteAsync(new StreamingRecognizeRequest
        {
            StreamingConfig = _streamingConfig,
        });
        return _streamingRecognizeStream.GetResponseStream();
    }
    public async Task SendAudio(string payload)
    {
        await _streamingRecognizeStream.WriteAsync(new StreamingRecognizeRequest
        {
            AudioContent = ByteString.FromBase64(payload)
        });
    }
}

This service is responsible for initializing the Google speech client. When you first create the stream, you write the recognition configuration as shown in the InitStream method. This returns the response stream; from that point on, you only write the audio data to the stream via the SendAudio method.

To be able to use these services with dependency injection, add them to the IoC container:

```csharp hl_lines=”3 4 5” builder.Services.AddSwaggerGen(); builder.Services.AddSpeechClient(); builder.Services.AddTransient(); builder.Services.AddTransient(); var app = builder.Build();


Make sure to add the using statement to the top of the file as well:

```csharp
using PlayAudioUsingMediaStreams.WebApi.Services;

Finally, update the AnimalSoundboardController as shown below:

using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Google.Api.Gax.Grpc;
using Google.Cloud.Speech.V1;
using Microsoft.AspNetCore.Mvc;
using PlayAudioUsingMediaStreams.WebApi.Services;
namespace PlayAudioUsingMediaStreams.WebApi.Controllers;
[ApiController]
[Route("[controller]")]
public class AnimalSoundboardController : Controller
{
    private readonly SoundService _soundService;
    private readonly SpeechRecognitionService _speechRecognitionService;
    private readonly IHostApplicationLifetime _applicationLifetime;
    public AnimalSoundboardController(
        SoundService soundService,
        SpeechRecognitionService speechRecognitionService,
        IHostApplicationLifetime applicationLifetime
    )
    {
        _soundService = soundService;
        _speechRecognitionService = speechRecognitionService;
        _applicationLifetime = applicationLifetime;
    }
    [HttpGet]
    public async Task Get()
    {
        if (HttpContext.WebSockets.IsWebSocketRequest)
        {
            using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
            await Soundboard(webSocket);
        }
        else
        {
            HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
        }
    }
    private async Task Soundboard(WebSocket webSocket)
    {
        string streamSid = null;
        var buffer = new byte[1024 * 4];
        var receiveResult = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
        await using var speechRecognitionStream = await _speechRecognitionService.InitStream();
        while (!receiveResult.CloseStatus.HasValue &&
               !_applicationLifetime.ApplicationStopping.IsCancellationRequested)
        {
            using var jsonDocument = JsonDocument.Parse(Encoding.UTF8.GetString(buffer, 0, receiveResult.Count));
            var eventMessage = jsonDocument.RootElement.GetProperty("event").GetString();
            switch (eventMessage)
            {
                case "connected":
                    Console.WriteLine("Event: connected");
                    break;
                case "start":
                    Console.WriteLine("Event: start");
                    streamSid = jsonDocument.RootElement.GetProperty("streamSid").GetString();
                    Console.WriteLine($"StreamId: {streamSid}");
                    // Do not await task, leave this task running in the background for the duration of the websocket connection
                    var _ = ListenForSpeechRecognition(webSocket, streamSid, speechRecognitionStream)
                        .ConfigureAwait(false);
                    break;
                case "media":
                    var payload = jsonDocument.RootElement.GetProperty("media").GetProperty("payload").GetString();
                    await _speechRecognitionService.SendAudio(payload);
                    break;
                case "stop":
                    Console.WriteLine("Event: stop");
                    break;
            }
            receiveResult = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
        }
        if (receiveResult.CloseStatus.HasValue)
        {
            await webSocket.CloseAsync(
                receiveResult.CloseStatus.Value,
                receiveResult.CloseStatusDescription,
                CancellationToken.None);
        }
        else if (_applicationLifetime.ApplicationStopping.IsCancellationRequested)
        {
            await webSocket.CloseAsync(
                WebSocketCloseStatus.EndpointUnavailable,
                "Server shutting down",
                CancellationToken.None);
        }
    }
    private async Task ListenForSpeechRecognition(
        WebSocket webSocket,
        string streamSid,
        AsyncResponseStream<StreamingRecognizeResponse> speechRecognitionStream
    )
    {
        while (await speechRecognitionStream.MoveNextAsync())
        {
            var word = speechRecognitionStream.Current?.Results.FirstOrDefault()
                ?.Alternatives.FirstOrDefault()
                ?.Words.FirstOrDefault();
            if (word == null) continue;
            Console.WriteLine($"Word: [{word.Word}]. Confidence: {word.Confidence:N2}");
            if (word.Confidence < 0.5)
            {
                Console.WriteLine($"Low confidence. Skipping the word [{word.Word}]");
                continue;
            }
            var utterance = word.Word.Trim().ToLower();
            if (!_soundService.TryFindSoundByKeyword(utterance, out var soundToPlay))
            {
                continue;
            }
            Console.WriteLine($"Animal detected: {soundToPlay.Name}");
            var mediaMessage = new
            {
                streamSid,
                @event = "media",
                media = new
                {
                    payload = soundToPlay.AudioDataAsBase64
                }
            };
            var rawJson = JsonSerializer.Serialize(mediaMessage);
            var responseBuffer = Encoding.UTF8.GetBytes(rawJson);
            await webSocket.SendAsync(
                new ArraySegment<byte>(responseBuffer, 0, responseBuffer.Length),
                WebSocketMessageType.Text,
                true,
                CancellationToken.None);
        }
    }
}

As mentioned before, now you’re parsing the JSON message:

var jsonDocument = JsonDocument.Parse(Encoding.UTF8.GetString(buffer, 0, receiveResult.Count))

First action is to determine the message type. You achieve this by parsing the event property:

string eventMessage = jsonDocument.RootElement.GetProperty("event").GetString();

As you saw at the beginning of the article, there are different types of events. In the application, you’re interested in 3 of them:

  • connected: This is where you initialize your Google speech client and the stream.

  • start: You receive the unique stream identifier in this message. This id must be stored to be able to send audio back to the caller.

  • media: Whenever you receive a media message, you parse the payload and send it to Google for speech recognition.

The final important update is to use both sound and speech recognition services to identify if a keyword was uttered by the user. If this happens, you prepare a new media message, convert it to JSON and send it to the user.

var mediaMessage = new
{
    streamSid, 
    @event = "media", 
    media = new
    {
        payload = soundToPlay.AudioDataAsBase64
    }
};

To test the final version, rerun your application and call your phone.

Speak some of the keywords, and you should hear the corresponding animals’ sounds:

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 10

How to Add More Audio

If you enjoyed this little project and would like to add more sounds, here’s how I created the stock sounds:

Go to BBC Sound Effects website.

Search for the animal you’re looking for, click the download button, and select wav as the file format.

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 11

Once you’ve downloaded the file, go to G711 File Converter.

Locate your file by clicking Browse.

Select u-Law WAV as the output format and click Submit.

Click on the link of the converted file to download it.

Most audio files are too long to be able to play one after another quickly. I use Audacity to open the files and copy the part I’m interested in.

Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 12

Once you’ve selected the portion you want, click File → Export → Export Selected Audio to save it as a separate file.

Conclusion

In this tutorial, you learned how WebSockets work and how to use them in a voice application to establish a 2-way audio connection to the caller. You also learned more about media streams and the audio format standard for telephony. You used all this knowledge to implement a project to play audio files to the user based on their commands. This project shows you have the ability to access raw audio and partial transcriptions. Now you can use this to implement your own projects.

If you’d like to keep learning, I recommend taking a look at these articles:

dev sendgrid, dotnet, email, twilio

This article was originally published on the Twilio Blog.

Email communication is an essential aspect of most businesses. In this post, you will look into the basics of sending emails with the Twilio SendGrid Email API and sending templated emails with the Handlebars templating language. Finally, you will finish by putting it together in a sample project that sends emails based on a template.

Prerequisites

You’ll need the following things for this tutorial:

Set Up SendGrid

API Key

First things first: To use the SendGrid API, you need an API key. Create one by heading over to the SendGrid dashboard and clicking on Settings → API Keys on the left menu and clicking the “Create API Key” button in the top-right corner:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 1

Next, give your key a name and select the permissions. You can choose Restricted Access to pick individual permissions.

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 2

For brevity, in this tutorial, select Full Access. After selecting Full Access, click “Create & View” to finish the key creation process.

The final step is crucial: The key will be displayed one time and one time only.

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 3

When the key is displayed, click on the key, which copies it to the clipboard. Then, keep it in a safe place, such as a password manager, and click Done.

Sender Email

Every email you send must be sent from a verified email address or domain. To create a sender, click Sender Authentication in the left menu.

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 4

Here you can verify a single address or an entire domain. Verifying the entire domain requires access to DNS settings, so to keep things simple, you will use single address verification.

To achieve this, click Verify a Single Sender on the Sender Authentication page (or Get Started if you don’t have any previously verified email addresses).

You should land on Create a Sender form:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 5

Fill in the details and click Create. The next step is to wait for the verification email:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 6

It should momentarily appear in your mailbox. Find the email and click on the Verify Single Sender button in the email:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 7

This takes you to the confirmation page:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 8

Now that you have an API key and a valid sender address, you can move on to sending emails.

Send Emails with SendGrid

Now that you have an API key, you’ll create a simple .NET application to test sending emails. (You can find the complete source code on this GitHub repository)

First, open a terminal and run the following commands to create a blank Console application and add the SendGrid NuGet package:

dotnet new console -o ConsoleMailer
cd ConsoleMailer
dotnet add package SendGrid

API keys are sensitive information that you should keep secret, so you should avoid hard coding them or committing them to source control. That’s why you should store the API key in an environment variable or a secure vault service. To keep things simple, you will store the API key in an environment variable.

For macOS and Linux, set the environment variable like this:

export SENDGRID_API_KEY={your key}

If you’re using PowerShell on Windows or another OS, use this command:

$Env:SENDGRID_API_KEY = "{your key}"

If you’re using CMD on Windows, use this command:

set "SENDGRID_API_KEY={your key}"

Replace {your key} with the API key secret you copied earlier.

Open the project in your preferred editor and find the Program.cs file. Update the Program.cs file with the following code:

using SendGrid;
using SendGrid.Helpers.Mail;
var apiKey = Environment.GetEnvironmentVariable("SENDGRID_API_KEY");
var client = new SendGridClient(apiKey);
var from = new EmailAddress("{ Your verified email address }", "{ Sender display name }");
var to = new EmailAddress("{ Recipient email address }", "{ Recipient display name }");

The code block above is going to be shared among the examples used in this article. You’ll need to replace { Your verified email address } with the SendGrid Single Sender email address you created earlier, and { Sender display name } with any name you prefer. The name will be displayed to the recipients. Then replace { Recipient email address} and { Recipient display name} with your desired recipient email address and name.

Then, append the following code block:

var subject = "Testing the API key";
var plainTextContent = "Testing a simple email";
var htmlContent = "<strong>Testing simple email in HTML</strong>";
var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent);
var response = await client.SendEmailAsync(msg);
if (response.IsSuccessStatusCode)
{
    Console.WriteLine("Email has been sent successfully");
}

The received email looks like this:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 9

!!!info

The methods from the MailHelper class like CreateSingleEmail all create a SendGridMessage object. You can also create a SendGridMessage object yourself, but the MailHelper class provides some convenient methods to do this for you for common scenario’s.

!!!

Dynamic Email Templates

Even though the data inside the email changes from email to email (such as the recipient name), most emails are based on some template. SendGrid has a powerful template designer that you can use to create dynamic templates, but you can also write the templates using code yourself.

To create your templates, navigate to the SendGrid Dashboard, click Email API and then Dynamic templates on the left menu. In the Dynamic Templates screen, click the “Create a Dynamic” template button.

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 10

Give the template a name and click Create:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 11

One nice feature about templates is that they are versioned. So you can create a new version without losing the previous version of the template. This way you can easily roll back to an earlier version.

Create your first version by clicking Add Version:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 12

SendGrid has a lot of built-in email templates:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 13

In this example, you’ll create a template from scratch. So, click on Your Email Designs and click Blank Template:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 14

Now you have to select the editor. Here you have two options: Design Editor and Code Editor. If you already have the HTML of an email template, you can simply switch to the code editor and start using that as a starting point.

In this example, you will use the designer, which simplifies designing email templates quite a bit. You can drag and drop the modules into the designer area. You can even test your emails directly in the designer.

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 15

You can also use test data to ensure it looks good while still designing.

For example, to test this design that uses the recipientName variable as a placeholder, you can click Preview and Show Test Data. Then you can enter your variable value as JSON and see the output.

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 16

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 17

At this point, you can design the email template to your liking by using basic modules such as text, image, HTML code, divider, etc.

The “dynamic” part comes from the ability to use placeholders, which then can be replaced with actual values. To understand how that works, you’ll need to understand how the templating support works with SendGrid. SendGrid emails support the Handlebar templating language (both in transactional templates and marketing campaign designs). Next, you will look into the basics of Handlebars to understand how you can leverage it to create dynamic data-driven templates.

Handlebars Templating Language

Handlebars is a commonly used templating language. It’s simple and quite powerful for creating dynamic templates.

Variable Substitution

Simple variable replacement is widely used in dynamic templates. As shown in the previous section, you can place a variable in your template by using it between opening and closing double-curly braces, such as:

Hello, {{recipientName}}

When you use a template such as this, you’ll need to provide data that includes the value; otherwise, it’s left blank. The nice thing is at least the placeholder is still replaced with an empty string so that it doesn’t appear in the final email, which would look very ugly and amateurish.

In C#, you can leverage normal .NET objects, including anonymous types, to provide dynamic data. For example, to provide data to the template above, you can use an anonymous object like this:

var dynamicEmailData = new
{
    recipientName = "Demo User",
};

To avoid leaving the variables blank, you can also provide default values by using the insert keyword. For example,

Hello,{{ insert recipientName "default=Valued User" }}

If you don’t provide recipientName in the data, the output looks like this:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 18

Variable Replacement in the Subject Field

In the designer, you can manually set a subject for your emails, but you can make this field dynamic as well. Variable substitution works for the subject field too. You can set the subject value as a variable using Handlebar notation:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 19

You are not obligated to replace the entire subject line. You can use a variable inside a longer hard-coded string in your template such as:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 20

To replace the placeholder with the actual value, in your code, you can just pass the actual value through template data:

var dynamicTemplateData = new
{
    subject = $"To-Do List for {DateTime.UtcNow:MMMM}"
};

HTML Replacement

You can also provide HTML to be injected into the template as well. The key point is that those values need to be marked with three curly braces. For example, if you want to replace the recipient name with HTML, you would use:

Hello, {{{recipientName}}}

And the object we provide would look like this:

var dynamicEmailData = new
{
    recipientName = "<b><i>Demo User</i></b>",
};

The output now looks like this:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 21

!!!warning Keep in mind that whenever you are using three curly braces, the variable will not be encoded and susceptible to HTML injection. If this variable holds user input, this can be risky! Make sure to use the HtmlEncoder in .NET to encode user input before passing it into the three curly braces. !!!

Iterations

Working with arrays happens quite often when creating templated emails. Handlebars has support for handling lists and iterating over them using the each keyword. For example, in the upcoming sample, you will list a number of to-do list items. This can be achieved by the code snippet below:

<table>
  <tr>
    <th>Title</th>
    <th>Due Date</th>
    <th>Status</th>
  </tr>
  {{#each todoItemList}}
    <tr>
      <td>{{this.Title}}</td>
      <td>{{this.DueDate}}</td>
      <td>{{this.Status}}</td>
    </tr>
  {{/each}}  
</table>

The items in the array can be addressed by using this keyword, as shown above.

Conditionals

Handlebars Templating Language also has the ability to apply some basic logic using and/or operators, if/else statements, comparison (less than/greater than/equals) and length operator to get the number of characters in a string or the number of items in an array.

Empty strings and zero numbers evaluate to false. For example, the code snippet below shows the username if the variable is a non-empty string:

{{#if this.username}}
  <h1>Hello {{username}}</h1>
{{/if}}

You can also check against the number of array lengths. In the example below, the “You have unread messages in your mailbox!” message will only be shown if the unreadMessages array has elements in it.

{{#greaterThan (length unreadMessages) 0}}
  <p>You have unread messages in your mailbox!</p>
{{else}}
    <p>No unread messages.</p>
{{/greaterThan}}

With the following data, it displays the message:

{
  "unreadMessages": [ "Message 1", "Message 2" ]
}

But in the case of an empty array, it shows “No unread messages.”.

More Handlebars

In addition to the features covered above, you can use conditionals and some basic logic to implement more complicated templates. You can find out more about using Handlebars with Twilio SendGrid here.

Send Email Using Dynamic Email Templates

Set Up Dynamic Email Template

First, go to SendGrid Email templates page.

Then, expand your dynamic template and click on the active version:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 22

Update the subject field with {{subject}} as you’re going to replace it with dynamic data.

Remove all the elements in the design area. Your design should look like this:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 23

On the left pane, click Build. Drag and drop a Text object into the design area. Replace the placeholder text with

Hello, {{recipientName}}
Here's your to-do list:

In this example, I set the line height to 40. You can play around with text properties to your liking.

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 24

Click on anywhere in the design area outside the Text element to see the available elements on the left pane again.

Click Code and drag it under the text element. This automatically opens the HTML editor. Paste the following code in the editor:

<table>
  <tr>
    <th>Title</th>
    <th>Due Date</th>
    <th>Status</th>
  </tr>
  {{#each todoItemList}}
    <tr>
      <td>{{this.title}}</td>
      <td>{{this.dueDate}}</td>
      <td>{{this.status}}</td>
    </tr>
  {{/each}}  
</table>

Click Update. Your final version of the template should look like this:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 25

Set Up C# Application

Open the Program.cs file in the ConsoleMailer project you created previously, and replace the previous email sending code with the highlighted Dynamic Email Template code:

csharp hl_lines="9 10 11 12 13 14 15 16 17 18 19 20 21 22" using SendGrid; using SendGrid.Helpers.Mail; var apiKey = Environment.GetEnvironmentVariable("SENDGRID_API_KEY"); var client = new SendGridClient(apiKey); var from = new EmailAddress("{ Your verified email address }", "{ Sender display name }"); var to = new EmailAddress("{ Recipient email address }", "{ Recipient display name }"); var templateId = "{ Your dynamic template id }"; var dynamicTemplateData = new { subject = $"To-Do List for {DateTime.UtcNow:MMMM}", recipientName = "Demo User", todoItemList = new[] { new { title = "Organize invoices", dueDate = "11 June 2022", status = "Completed" }, new { title = "Prepare taxes", dueDate = "12 June 2022", status = "In progress" }, new { title = "Submit taxes", dueDate = "25 June 2022", status = "Pending" }, } }; var msg = MailHelper.CreateSingleTemplateEmail(from, to, templateId, dynamicTemplateData); var response = await client.SendEmailAsync(msg); if (response.IsSuccessStatusCode) { Console.WriteLine("Email has been sent successfully"); }

!!!info

Make sure the name and casing of the variables in your template match the dynamic template data you’re passing in. For example, if your subject variable is {{subject}} and your C# data has a property named Subject, the subject of your email will be empty.

!!!

This code is similar to the previous code with a few differences:

  • You call the CreateSingleTemplateEmail method instead of the CreateSingleEmail method

  • You provide the data displayed in the final email output, but you don’t render the HTML yourself.

Also, you must provide the unique template ID. You can obtain the ID from the Dynamic Templates page. Expand the details of your template and the Template ID should appear at the top:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 26

Once you’ve obtained the template ID, replace { Your dynamic template id } with it in your code.

The template ID is shared among all versions, so you don’t have to change your configuration when you create a new version. However, you have to ensure to choose the correct version as active. To make a version active, click on the vertical three dots on the right and click Make Active.

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 27

The final email looks like this:

Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 28

You can see how the todoItemList array is used to render the to-do item list table in a dynamic fashion.

Conclusion

In this post, you looked into the basics of setting up your SendGrid account and the SendGrid .NET library. Then you learned how to use Dynamic Email Templates and how you can use Handlebar templating to create dynamic emails. You can find the source code for the applications in this tutorial on GitHub.

Twilio SendGrid offers a lot more than covered in this article. I’d recommend visiting the official documentation and discovering more features based on your use cases. Here are a couple of articles about sending emails and templating that could help you get the most out of SendGrid and .NET:

dev rss

I have a few old podcast series that are not available online anymore. Every now and then, I enjoy listening to an old episode. I keep them in a hard drive connected to a Raspberry Pi, which serves them over the local network. Then I connect to this share on my mobile device and consume the content. It works fine most of the time. The problem is it’s hard to remember the last episode I listened to since everything is treated as files with no history. I thought I could leverage my podcast app on my phone if I served these files via an RSS feed. This tutorial will show how to generate the RSS feed using C# and serve the content over your local network. If this sounds like a problem you would like to solve, let’s get started.

Prerequisites

To follow this tutorial, you need the following software installed:

Set up the Web Server

Since you will host only static files (RSS feed which is an XML file and some audio files), an Nginx instance running in a Docker container is sufficient.

First, designate a local directory on your computer to put the files. In the tutorial, I will use the following path: ~/Temp/webroot. Modify this to match your environment.

Run the following command to start your podcast server:

docker run --name podcast-server -p 9876:80 -v ~/Temp/webroot:/usr/share/nginx/html:ro -d nginx

The command above;

  • Maps port 9876 on your machine to the internal port 80 in the container. (-p 9876:80)

  • Runs the container in the background as a daemon (-d)

  • Mounts the ~/Temp/webroot directory on your machine to the /usr/share/nginx/html directory on the container. This means when Nginx serves content in its HTML directory, it looks into the ~/Temp/webroot directory. This way, you can manage the content without going into the container’s file system.

If you open a browser tab and go to http://localhost:9876, you should get a 403 Forbidden response from the web server. This is expected because you haven’t put any files to serve yet.

Set up Content

To test the application, let’s start with a small amount of content. Go to file-examples.com and download 2 MP3 files and rename them as “episode1.mp3” and “episode2.mp3”. So the root of your web server should look like this:

Contents of the root directory showing webroot directory, content directory under it and two files named episode1.mp3 and episode2.mp3

Now, if you request one of these files in your browser (e.g. http://localhost:9876/content/episode1.mp3), you should be able to hear the MP3 playing. In the next section, you will implement the application that creates the RSS feed so that you can consume the feed via your podcatcher too.

Implement the RSS Generator

An RSS feed is simply an XML file. It includes the name and description of the show, as well as the titles and URLs of the individual episodes. If this feed were meant to be published publicly, you would add more details such as icons, categories, iTunes-specific tags etc., but for personal consumption, the following format is sufficient:

<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
  <channel>
    <title>{Show Title}</title>
    <description>{Show Description}</description>
    <category>{Category}</category>
    <item>
      <title>{Episode Title}</title>
      <description>{Episode Description}</description>
      <enclosure url="{Episode URL}" type="audio/mpeg"/>
    </item>
  </channel>
</rss>

Create a new dotnet console application:

dotnet new console --name RssFeedGenerator --output .

Open the project with your IDE.

To serialize to the XML above, you will need a data structure. There are tools that can generate C# classes from a sample XML, so you don’t have to manually create the classes yourself. I generated the following at Xml2Charp. The result looks like this (after a bit of formatting):

/* 
 Licensed under the Apache License, Version 2.0
 
 http://www.apache.org/licenses/LICENSE-2.0
 */

using System.Xml.Serialization;

namespace RssFeedGenerator
{
    [XmlRoot(ElementName="enclosure")]
    public class Enclosure
    {
        [XmlAttribute(AttributeName="url")]
        public string Url { get; set; }
        [XmlAttribute(AttributeName="type")]
        public string Type { get; set; }
    }

    [XmlRoot(ElementName="item")]
    public class Item
    {
        [XmlElement(ElementName="title")]
        public string Title { get; set; }
        [XmlElement(ElementName="description")]
        public string Description { get; set; }
        [XmlElement(ElementName="enclosure")]
        public Enclosure Enclosure { get; set; }
    }

    [XmlRoot(ElementName="channel")]
    public class Channel
    {
        [XmlElement(ElementName="title")]
        public string Title { get; set; }
        [XmlElement(ElementName="description")]
        public string Description { get; set; }
        [XmlElement(ElementName="category")]
        public string Category { get; set; }
        [XmlElement(ElementName="item")]
        public List<Item> Item { get; set; }
    }

    [XmlRoot(ElementName="rss")]
    public class Rss
    {
        [XmlElement(ElementName="channel")]
        public Channel Channel { get; set; }
        [XmlAttribute(AttributeName="version")]
        public string Version { get; set; }
    }
}

Create a file called Rss.cs in your project and paste the above code. The biggest change I made to the auto-generated version is to replace the single Item property in the Channel class with a **List**, as you will need multiple entries per podcast.

Now, update Program.cs with the code below:

using System.Xml.Serialization;
using RssFeedGenerator;

string serverIPAddress = "192.168.1.20";
int serverPort = 9876;
var contentFullPath = "/Temp/webroot/content";
var feedFullPath = "/Temp/webroot/feed.rss";
var audioRootUrl = $"http://{serverIPAddress}:{serverPort}";

var rss = new Rss
{
    Version = "2.0",
    Channel = new Channel
    {
        Title = "[Local] Test Podcast",
        Description = "Testing generating RSS feed from local MP3 files",
        Category = "test",
        Item = new List<Item>()
    }
};

var allMp3s = new DirectoryInfo(contentFullPath)
    .GetFiles("*.mp3", SearchOption.AllDirectories)
    .OrderBy(x => x.Name);

foreach (var mp3 in allMp3s)
{
    rss.Channel.Item.Add(new Item
    {
        Description = mp3.Name,
        Title = mp3.Name,
        Enclosure = new Enclosure
        {
            Url = $"{audioRootUrl}/{mp3.Directory.Name}/{mp3.Name}",
            Type = "audio/mpeg"
        }
    });
}

var serializer = new XmlSerializer(typeof(Rss));
using (var writer = new StreamWriter(feedFullPath))
{
    serializer.Serialize(writer, rss);
}

Make sure to update the settings at the top of the file before you run the application.

Run the application by running the following command in the terminal:

dotnet run

Under your web server’s root directory, you should see the feed.rss file that looks like this:

<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" version="2.0">
  <channel>
    <title>[Local] Test Podcast</title>
    <description>Testing generating RSS feed from local MP3 files</description>
    <category>test</category>
    <item>
      <title>episode1.mp3</title>
      <description>episode1.mp3</description>
      <enclosure url="http://192.168.1.20:9876/content/episode1.mp3" type="audio/mpeg" />
    </item>
    <item>
      <title>episode2.mp3</title>
      <description>episode2.mp3</description>
      <enclosure url="http://192.168.1.20:9876/content/episode2.mp3" type="audio/mpeg" />
    </item>
  </channel>
</rss>

At this point, you have your RSS feed and all your content under your web server. The final step is to consume this content using your podcast app.

Add Your Podcast to your Podcast App

My podcast app of choice is Overcast. I’m very happy with it and have been using it for many years. The following might be a limitation of Overcast, but apparently, it cannot access feeds over the local network. So to tackle this issue, I used NGrok to tunnel web traffic to my local web server.

If you are having the same issue, install ngrok and run the following command:

ngrok http 9876

This should generate a public URL and route traffic to your local server. In my case, it looks like this:

ngrok output showing the traffic is routed to localhost:9876

Now you can access your feed via the {public URL}/feed.rss.

In Overcast, I add the URL by clicking the + button on the top right and then clicking Add URL link.

After it fetches and parses the RSS feed, it should appear in your podcast list.

The only thing that’s left is to open the podcast and play the episodes:

Even though Overcast cannot fetch the RSS feed over the local network, it can still play the episodes locally. You can stop ngrok and continue to play the episodes. The downside of this approach is if this podcast is an active one and you want to refresh the feed, you will need to delete and re-add the feed because the ngrok address will have changed the next time you try to get the updates.

Conclusion

I love hosting my own content in my own network. Even though having some offline podcasts stored locally is not a common use case, I had to implement my solution to solve the issue and decided to make it public and share it in case anyone else would like to use the same approach or build on it and make it better. The final source code can be found in my GitHub repository.