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: el procesamiento de audio a través del servicio Speech de Cognitive Services. Este 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 su transcripción.

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 se 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. Después, debo guardar la ruta en App Settings bajo este nombre, para que el código pueda ubicar el ejecutable. En mi caso lo guardo en D:\home\site\wwwroot\tools\ffmpeg.exe.
ffmpeg.exe subido a D:\home\site\wwwroot\tools

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
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.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;
using Microsoft.WindowsAzure.Storage;
using Microsoft.CognitiveServices.Speech;
using Microsoft.CognitiveServices.Speech.Audio;
using System.Linq;

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"));
            //https://docs.microsoft.com/en-us/azure/cognitive-services/speech-service/language-support (en-us by default)
            config.SpeechRecognitionLanguage = Environment.GetEnvironmentVariable("SpeechLanguage");

            var stopRecognition = new TaskCompletionSource<int>();

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

            //Create a temp file
            using (BinaryReader reader = new BinaryReader(myBlob))
            {
                var bytes = reader.ReadBytes((int)myBlob.Length);
                var outputStream = new MemoryStream(bytes, 0, bytes.Count());

                File.WriteAllBytes(temp, outputStream.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);

                    log.LogInformation("Speech recognition language: {0}", recognizer.SpeechRecognitionLanguage);

                    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í. Esta 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 esta 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.
  • SpeechLanguage: para elegir el idioma que en el cual se encuentran los audios. Aquí puedes encontrar todos los lenguajes soportados.

Si despliegas el ejemplo a través de la plantilla (azure.deploy.json) esta configuración se hace de manera automática.

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

 audio-functions WavToText
audio-functions WavToText

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

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

¡Feliz viernes!