Azure Functions para procesar MP3s con Speech Service

Estos días he estado jugando un rato largo con Azure Functions para realizar una tarea que encaja muy bien y es el procesamiento de audio a través del servicio Speech de Cognitive Services. Este servicio sólo soporta wav a la hora de realizar el proceso, por lo que he utilizado dos funciones serverless que me permiten primero convertir de MP3 a WAV y segundo recuperar ese WAV para obtener la transcripción del audio.

La primera función la he llamado Mp3ToWav y es de tipo blog trigger para que cada vez que se añada un nuevo mp3 dentro del container mp3s lance esta función para convertirlo a wav:

using System;
using System.IO;
using System.Diagnostics;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.Extensions.Logging;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;
using Microsoft.WindowsAzure.Storage.Core.Util;
using System.Threading;
using System.Threading.Tasks;

namespace returngis.function
{
    public static class Mp3ToWav
    {
        static long totalBytes = 0;
        static ILogger trace;

        [FunctionName("Mp3ToWav")]
        public async static void Run([BlobTrigger("mp3s/{name}", Connection = "AzureWebJobsStorage")]Stream myBlob, string name, ILogger log)
        {
            trace = log;
            trace.LogInformation($"Mp3ToWav function processed blob\n Name:{name} \n Size: {myBlob.Length} Bytes");

            var mp3Temp = string.Format("{0}.mp3", Path.GetTempFileName());
            var wavTemp = string.Format("{0}.wav", Path.GetTempFileName());

            using (var ms = new MemoryStream())
            {
                myBlob.CopyTo(ms);
                File.WriteAllBytes(mp3Temp, ms.ToArray());
            }

            var mp3Bytes = File.ReadAllBytes(mp3Temp);
            trace.LogInformation($"mp3 size: {ConvertBytesToMegabytes(mp3Bytes.Length).ToString("0.00")} MB");

            var process = new Process();

            process.StartInfo.FileName = Environment.GetEnvironmentVariable("ffmpegPath");
            process.StartInfo.Arguments = $"-i \"{mp3Temp}\" \"{wavTemp}\"";
            process.StartInfo.RedirectStandardOutput = true;
            process.StartInfo.RedirectStandardError = true;
            process.StartInfo.UseShellExecute = false;

            process.OutputDataReceived += new DataReceivedEventHandler((s, e) =>
            {
                trace.LogInformation($"O: {e.Data}");
            });

            process.ErrorDataReceived += new DataReceivedEventHandler((s, e) =>
            {
                trace.LogInformation($"E: {e.Data}");
            });

            process.Start();
            trace.LogInformation("***Converting from mp3 to wav***");
            process.BeginOutputReadLine();
            process.BeginErrorReadLine();
            process.WaitForExit();

            trace.LogInformation($"ffmpeg exit code: {process.ExitCode}");
            trace.LogInformation($"wav temp out exists: {File.Exists(wavTemp)}");

            var bytes = File.ReadAllBytes(wavTemp);
            trace.LogInformation($"wav size: {ConvertBytesToMegabytes(bytes.Length).ToString("0.00")} MB");

            trace.LogInformation("***Uploading wav to Azure Storage***");

            CloudStorageAccount storageAccount = CloudStorageAccount.Parse(Environment.GetEnvironmentVariable("AzureWebJobsStorage"));
            var client = storageAccount.CreateCloudBlobClient();
            var container = client.GetContainerReference("wavs");
            await container.CreateIfNotExistsAsync();

            var blob = container.GetBlockBlobReference(name.Replace("mp3", "wav"));
            blob.Properties.ContentType = "audio/wav";

            var progressHandler = new Progress<StorageProgress>();
            progressHandler.ProgressChanged += ProgressHandler_ProgressChanged;

            using (Stream stream = new MemoryStream(bytes))
            {
                totalBytes = File.Open(wavTemp, FileMode.Open).Length;

                try
                {
                    await blob.UploadFromStreamAsync(stream, null, new BlobRequestOptions(), new OperationContext(), progressHandler, CancellationToken.None);
                }
                catch (Exception ex)
                {
                    trace.LogInformation($"Error: {ex.Message}");
                }
            }


            //Delete temp files
            File.Delete(mp3Temp);
            File.Delete(wavTemp);

            trace.LogInformation("Done!");
        }

        static double ConvertBytesToMegabytes(long bytes)
        {
            return (bytes / 1024f) / 1024f;
        }
        private static void ProgressHandler_ProgressChanged(object sender, StorageProgress e)
        {
            double dProgress = ((double)e.BytesTransferred / totalBytes) * 100.0;

            trace.LogInformation($"{dProgress.ToString("0.00")}% bytes transferred: { ConvertBytesToMegabytes(e.BytesTransferred).ToString("0.00")} MB from {ConvertBytesToMegabytes(totalBytes).ToString("0.00")} MB");
        }
    }
}

Para que esta funcione correctamente debes configurar los siguientes valores en App Settings:

  • AzureWebJobsStorage: para el ejemplo estoy utilizando la cadena de conexión que se incluye por defecto. Este registro ya debería de estar dado de alta en tu servicio de Azure Function.
  • ffmpegPath: para hacer la conversión de mp3 a wav estoy utilizando la herramienta ffmpeg.exe, la cual debo descargar y alojar en algún path de mi servicio y luego establecerlo en esta variable para que sepa encontrarlo. En mi caso lo guardo en c:\home\site\wwwroot\tool\ffmpeg.exe. Esta debería de ser una tarea para Azure DevOps 😉

Una vez configurado, para probar que todo funciona correctamente debes crear un container dentro de la cuenta de Azure Storage llamado mp3s e incluir un audio en este formato. El output debería de ser parecido al siguiente:

audio-functions Mp3ToWav

Por otro lado tenemos una segunda función llamada WavToText que estará a la escucha de un container llamado wavs, que es donde deja la función anterior las conversiones de los mp3:

using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Diagnostics;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.Extensions.Logging;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;

using Microsoft.CognitiveServices.Speech;
using Microsoft.CognitiveServices.Speech.Audio;


namespace returngis.function
{
    public static class WavToText
    {
        [FunctionName("WavToText")]
        public async static void Run([BlobTrigger("wavs/{name}", Connection = "AzureWebJobsStorage")]Stream myBlob, string name, ILogger log)
        {
            log.LogInformation($"WavToText processed blob\n Name:{name} \n Size: {myBlob.Length} Bytes");

            var config = SpeechConfig.FromSubscription(Environment.GetEnvironmentVariable("SpeechKey"), Environment.GetEnvironmentVariable("SpeechRegion"));
            var stopRecognition = new TaskCompletionSource<int>();

            var temp = string.Format("{0}.wav", Path.GetTempFileName());

            using (var ms = new MemoryStream())
            {
                myBlob.CopyTo(ms);
                File.WriteAllBytes(temp, ms.ToArray());
            }

            log.LogInformation($"Temp file {temp} created.");

            using (var audioInput = AudioConfig.FromWavFileInput(temp))
            {
                using (var recognizer = new SpeechRecognizer(config, audioInput))
                {

                    var transcript = new StringBuilder();

                    // Subscribes to events.
                    recognizer.Recognizing += (s, e) =>
                    {
                        log.LogInformation($"RECOGNIZING: Text={e.Result.Text}");
                    };

                    recognizer.Recognized += (s, e) =>
                    {
                        if (e.Result.Reason == ResultReason.RecognizedSpeech)
                        {
                            log.LogInformation($"RECOGNIZED: Text={e.Result.Text}");
                            transcript.Append($" {e.Result.Text} ");
                        }
                        else if (e.Result.Reason == ResultReason.NoMatch)
                        {
                            log.LogInformation($"NOMATCH: Speech could not be recognized.");
                        }
                    };

                    recognizer.Canceled += (s, e) =>
                    {
                        log.LogInformation($"CANCELED: Reason={e.Reason}");

                        if (e.Reason == CancellationReason.Error)
                        {
                            log.LogInformation($"CANCELED: ErrorCode={e.ErrorCode}");
                            log.LogInformation($"CANCELED: ErrorDetails={e.ErrorDetails}");
                            log.LogInformation($"CANCELED: Did you update the subscription info?");
                        }

                        stopRecognition.TrySetResult(0);
                    };

                    recognizer.SessionStarted += (s, e) =>
                    {
                        log.LogInformation("\n    Session started event.");
                    };

                    recognizer.SessionStopped += (s, e) =>
                    {
                        log.LogInformation("\n    Session stopped event.");
                        log.LogInformation("\nStop recognition.");
                        log.LogInformation("\nTranscript: ");
                        log.LogInformation(transcript.ToString());
                        stopRecognition.TrySetResult(0);
                    };

                    // Starts continuous recognition. Uses StopContinuousRecognitionAsync() to stop recognition.
                    await recognizer.StartContinuousRecognitionAsync().ConfigureAwait(false);

                    Task.WaitAny(new[] { stopRecognition.Task });

                    // Stops recognition.
                    await recognizer.StopContinuousRecognitionAsync().ConfigureAwait(false);

                    //save the file in the transcripts container                    
                    CloudStorageAccount storageAccount = CloudStorageAccount.Parse(Environment.GetEnvironmentVariable("AzureWebJobsStorage"));
                    
                    var client = storageAccount.CreateCloudBlobClient();
                    var container = client.GetContainerReference("transcripts");
                    await container.CreateIfNotExistsAsync();

                    var blob = container.GetBlockBlobReference(name.Replace("wav", "txt"));
                    var transcriptTempFile = string.Format("{0}.txt", Path.GetTempFileName());
                    File.WriteAllText(transcriptTempFile, transcript.ToString());
                    await blob.UploadFromFileAsync(transcriptTempFile);

                    File.Delete(transcriptTempFile);
                    File.Delete(temp);
                }
            }
        }
    }
}

Para esta función me he basado en uno de los ejemplos que aparecen aquí y básicamente utiliza la función StartContinuousRecognitionAsync para reconocer todo lo que va apareciendo en el audio. En la demo me he quedado únicamente con el texto que reconoce pero es posible obtener más información.

Para que esta función pueda ser ejecutada también necesitas añadir algunos valores en el App Settings:

  • SpeechKey: se trata de la clave del servicio Speech que debes dar de alta para poder realizar el reconocimiento.
  • SpeechRegion: la región donde generaste la clave.

La salida de esta función será similar a esta:

audio-functions WavToText

Y si refrescas el contenido de tu Azure Storage verás que el resultado final será un container llamado transcripts con todas las transcripciones realizadas de tus audios.

Como siempre, el código lo tienes en GitHub 🙂

¡Feliz viernes!