告别云端“韭菜局”!C# + SherpaOnnx手搓离线语音识别,免费高精度本地运行

基于 SherpaOnnx 的高效语音识别控制台应用程序,支持多种音频和视频格式的语音识别,并能将识别结果导出为多种格式

缘起:

总有人向我抱怨:“哥,你推荐的美剧学英语,我辛辛苦苦下载下来,结果没字幕,像在看默剧!”、“录音转文字居然要收一杯奶茶钱,这合理吗?”、“做自媒体想提取文案,结果各大平台把我当韭菜割……”

我一琢磨,发现现在的语音识别市场简直就像“语音劫财”:免费?没门!高效?先充VIP!要识别更准确?得花钱买!本地部署?抱歉,只做云端!定制功能?加钱!

这哪是语音识别,分明是“语音收费”啊!

“穷且益坚,不坠青云之志。”

我们这种搞技术的,怎能被这点门槛困住?于是,我决定亲自动手,打造一个语音识别工具:免费如空气,高效如闪电,准确如考官,本地运行稳如老狗,定制灵活如搭乐高。

至于为何用 C# 实现?嘿嘿,其他语言当然也能做到,甚至效率更高,但本文特地为 C# 学习者争取一点福利。

下面,干货奉上,无赘述,直接开整。

一、依赖库

开发工具: visual studio 2022 + net 8

依赖库版本用途
CommandLineParser2.9.1命令行参数解析
FFMpegCore5.2.0视频转音频处理
NAudio2.2.1音频文件读取和处理
org.k2fsa.sherpa.onnx1.12.9语音识别核心库

二、下代码去

  1.  代码 在 gitee 上。(地址 : SpeechRecognitionConsoleApp: 本地语音识别,支持中文、英文、日文、韩文。开发语言为C#。本地模型为SenseVoice. 后续有时间,会集成其他模型。 https://gitee.com/yhlcode1/SpeechRecognition

  2.模型  模型下载地址: 迅雷其他下载工具即可。

三 、 代码结构解释

文件名主要功能
SpeechRecognitionConsoleApp.cs程序入口,处理命令行参数,协调整体工作流程
AudioReader.cs音频文件读取和处理(支持多格式、声道转换、采样率处理)
IAudioReader.cs音频读取器接口定义
Options.cs离线语音识别命令行参数选项定义实体类
ParseResult.cs识别结果处理(标点符号添加、时间戳计算、多格式导出)

  1. 指定文件处理:配置指定文件夹,然后识别

  2. 视频转音频:自动将视频文件转换为临时音频文件
     用到的技术是FFMpeg的net库

    /// 核心代码段
     // 如果是视频文件,先转换为mp3  .
     if (extension == ".mp4" || extension == ".avi" || extension == ".mkv" || extension == ".mov")
     {
         // 不要拿很长的视频文件测试。等待时间你会怀疑 every thing ...
         Console.WriteLine($"检测到视频文件: {file},正在转换为音频...");
         string tempDir = Path.Combine(Path.GetDirectoryName(file) ?? ".", "audioTmp");
         Directory.CreateDirectory(tempDir);
         string tempAudioFilePath = Path.Combine(tempDir, $"{Path.GetFileNameWithoutExtension(file)}_converted.mp3");
    
         try
         {
             // 使用FFMpegCore将视频转换为mp3
             FFMpegCore.FFMpegArguments
                 .FromFileInput(file)
                 .OutputToFile(tempAudioFilePath, true, options => options
                     .WithAudioBitrate(FFMpegCore.Enums.AudioQuality.Normal) // 128kbps
                     .WithAudioCodec("libmp3lame")
                     .WithAudioSamplingRate(16000) // 采样率16000Hz
                     .WithCustomArgument("-ac 1") // 单声道
                     .WithCustomArgument("-sample_fmt s16") // 位深度16位
                 )
                 .ProcessSynchronously();
    
             audioFilePath = tempAudioFilePath;
             tempFiles.Add(tempAudioFilePath);
             //processedFiles.Add(tempAudioFilePath);
             Console.WriteLine($"视频文件转换成功,生成音频文件: {audioFilePath}");
         }
         catch (Exception ex)
         {
             Console.WriteLine($"视频转换失败: {ex.Message},将尝试直接处理原文件");
         }
     }
    

  3. 音频读取:使用 AudioReader 读取音频数据
    用到当前流行的 NAudio 库
     

            /// <summary>
            /// 读取音频文件
            /// </summary>
            /// <param name="fileName">音频文件路径</param>
            private void ReadAudioFile(string fileName)
            {
                // 初始化MediaFoundation,用于支持MP3、MP4等格式
                MediaFoundationApi.Startup();
    
                try
                {
                    using (var audioFileReader = new AudioFileReader(fileName))
                    {
                        // 获取采样率
                        sampleRate = audioFileReader.WaveFormat.SampleRate;
    
                        // 读取所有音频样本
                        int samplesCount = (int)(audioFileReader.Length / audioFileReader.WaveFormat.BlockAlign);
                        samples = new float[samplesCount * audioFileReader.WaveFormat.Channels];
                        audioFileReader.Read(samples, 0, samples.Length);
    
                        // 如果是多声道,转换为单声道
                        if (audioFileReader.WaveFormat.Channels > 1)
                        {
                            float[] monoSamples = new float[samplesCount];
                            for (int i = 0; i < samplesCount; i++)
                            {
                                // 简单平均所有声道
                                float sum = 0;
                                for (int j = 0; j < audioFileReader.WaveFormat.Channels; j++)
                                {
                                    sum += samples[i * audioFileReader.WaveFormat.Channels + j];
                                }
                                monoSamples[i] = sum / audioFileReader.WaveFormat.Channels;
                            }
                            samples = monoSamples;
                        }
                    }
                }
                finally
                {
                    // 关闭MediaFoundation
                    MediaFoundationApi.Shutdown();
                }
            }

  4. 大文件分割:对超过20分钟的音频文件进行智能分割
    获取到文件音频的播放长度,用到的NAudio 库的方法。
     

            /// <summary>
            /// 分割音频文件为最长20分钟的块,并将分割后的wav文件压缩为mp3
            /// </summary>
            /// <param name="filePath">原始音频文件路径</param>
            /// <returns>压缩后的mp3文件路径列表</returns>
            public static List<string> SplitAudioFile(string filePath)
            {
                var resultFiles = new List<string>();
                var directory = Path.GetDirectoryName(filePath);
                var fileName = Path.GetFileNameWithoutExtension(filePath);
                
                using (var audioFileReader = new NAudio.Wave.AudioFileReader(filePath))
                {
                    // 5分钟的字节数 - 减小分割文件大小
                    long bytesPerBlock = (long)(audioFileReader.WaveFormat.AverageBytesPerSecond * 600);
                    long position = 0;
                    int blockIndex = 0;
                    
                    // 确保目标目录存在 - 使用当前文件所在目录下的audioTmp文件夹
                    string targetDir = Path.Combine(directory ?? ".", "audioTmp");
                    Directory.CreateDirectory(targetDir);
                    
                    while (position < audioFileReader.Length)
                    {
                        // 计算当前块的实际大小
                        long blockSize = Math.Min(bytesPerBlock, audioFileReader.Length - position);
                        
                        // 创建临时wav文件路径
                        string wavFilePath = Path.Combine(
                            targetDir, 
                            $"{fileName}_part{blockIndex}.wav");
                        
                        // 创建目标mp3文件路径
                        string mp3FilePath = Path.Combine(
                            targetDir, 
                            $"{fileName}_part{blockIndex++}.mp3");
                        
                        // 读取并保存音频块为wav
                        using (var writer = new NAudio.Wave.WaveFileWriter(wavFilePath, audioFileReader.WaveFormat))
                        {
                            audioFileReader.Position = position;
                            var buffer = new byte[blockSize];
                            int bytesRead = audioFileReader.Read(buffer, 0, buffer.Length);
                            writer.Write(buffer, 0, bytesRead);
                        }
                        
                        // 使用FFMpegCore将wav文件压缩为mp3
                        FFMpegCore.FFMpegArguments
                            .FromFileInput(wavFilePath)
                            .OutputToFile(mp3FilePath, true, options => options
                                .WithAudioBitrate(FFMpegCore.Enums.AudioQuality.Normal) // 128kbps
                                .WithAudioCodec("libmp3lame")
                                .WithAudioSamplingRate(16000) // 采样率16000Hz
                                .WithCustomArgument("-ac 1") // 单声道 (替代WithAudioChannels)
                                .WithCustomArgument("-sample_fmt s16") // 位深度16位
                            )
                            .ProcessSynchronously(); // 使用ProcessSynchronously替代Process(true)
                        
                        // 将mp3文件路径添加到结果列表
                        resultFiles.Add(mp3FilePath);
                        
                        // 可选:删除临时wav文件以节省空间
                         File.Delete(wavFilePath);
    
                        position += blockSize;
                    }
                }
                
                return resultFiles;
            }

  5. 语音识别:使用 SherpaOnnx 进行语音识别
     

        /// <summary>
        /// 运行语音识别
        /// 配置识别器并处理音频文件
        /// </summary>
        /// <param name="options">命令行参数选项</param>
        private static void Run(Options options)
        {
            // 创建离线识别器配置
            OfflineRecognizerConfig? config = GetRecognizerConfig(options);
            if (config == null)
            {
                return;
            }
    
            var recognizer = new OfflineRecognizer((OfflineRecognizerConfig)config);
    
            var files = options.Files.ToArray();
            const int batchSize = 2; // 一次处理的文件数量,根据内存情况调整
    
            // 分批处理文件以减少内存使用
            for (int batchStart = 0; batchStart < files.Length; batchStart += batchSize)
            {
                int currentBatchSize = Math.Min(batchSize, files.Length - batchStart);
                Console.WriteLine($"处理批次 {batchStart / batchSize + 1}/{(files.Length + batchSize - 1) / batchSize},文件数量: {currentBatchSize}");
    
                // 为当前批次创建流
                var streams = new List<OfflineStream>();
                var streamFileMap = new Dictionary<OfflineStream, string>(); // Map stream to file path
                var streamChunkMap = new Dictionary<OfflineStream, int>(); // Map stream to chunk index
    
                for (int i = 0; i < currentBatchSize; ++i)
                {
                    string filePath = files[batchStart + i];
                    AudioReader audioReader = new AudioReader(filePath);
                    float[] samples = audioReader.Samples;
                    int sampleRate = audioReader.SampleRate;
    
                    // 进一步减小文件块大小,减少每个流的内存占用
                    const int maxSamplesPerChunk = 500000; // 每个块的最大样本数,可根据内存调整
                    if (samples.Length < maxSamplesPerChunk || options.NumThreads <= 1)
                    {
                        // 小文件直接处理
                        var s = recognizer.CreateStream();
                        s.AcceptWaveform(sampleRate, samples);
                        streams.Add(s);
                        streamFileMap[s] = filePath;
                        streamChunkMap[s] = -1; // -1 indicates whole file
                    }
                    else
                    {
                        // 大文件分成更多小块
                        int numChunks = (samples.Length + maxSamplesPerChunk - 1) / maxSamplesPerChunk;
                        for (int chunkIndex = 0; chunkIndex < numChunks; ++chunkIndex)
                        {
                            int startIndex = chunkIndex * maxSamplesPerChunk;
                            int actualChunkSize = Math.Min(maxSamplesPerChunk, samples.Length - startIndex);
    
                            float[] chunkSamples = audioReader.ReadSamples(startIndex, actualChunkSize);
                            var s = recognizer.CreateStream();
                            s.AcceptWaveform(sampleRate, chunkSamples);
                            streams.Add(s);
                            streamFileMap[s] = filePath;
                            streamChunkMap[s] = chunkIndex;
                        }
                    }
    
                    // 释放audioReader资源
                    audioReader = null;
                    samples = null;
                }
    
                // 处理当前批次的流
                Console.WriteLine($"--------处理当前批次的 {streams.Count} 个流--------------------");
                recognizer.Decode(streams);
                Console.WriteLine("-----当前批次解码完成-------------------");
    
                // Group streams by file path for result merging
                var groupedResults = new Dictionary<string, List<(OfflineStream Stream, int ChunkIndex)>>();
                // 记录开始时间
                DateTime startTime = DateTime.Now;
    
                foreach (var stream in streams)
                {
                    string filePath = streamFileMap[stream];
                    int chunkIndex = streamChunkMap[stream];
    
                    if (!groupedResults.ContainsKey(filePath))
                    {
                        groupedResults[filePath] = new List<(OfflineStream, int)>();
                    }
                    groupedResults[filePath].Add((stream, chunkIndex));
                }
    
                // 计算并显示耗时
                DateTime endTime = DateTime.Now;
                TimeSpan duration = endTime - startTime;
                Console.WriteLine($"流分组处理耗时: {duration.TotalMilliseconds:F2} 毫秒");
    
                // Sort chunks by index and merge results
                foreach (var fileGroup in groupedResults)
                {
                    string filePath = fileGroup.Key;
                    var sortedChunks = fileGroup.Value.OrderBy(chunk => chunk.ChunkIndex).ToList();
    
                    // If there's only one chunk (or whole file), display as before
                    if (sortedChunks.Count == 1 && sortedChunks[0].ChunkIndex == -1)
                    {
                        var stream = sortedChunks[0].Stream;
                        var r = stream.Result;
                        DisplayResult(filePath, r);
                    }
                    else
                    {
                        // For multi-chunk files, merge results
                        StringBuilder mergedText = new StringBuilder();
                        List<string> mergedTokens = new List<string>();
                        List<float> mergedTimestamps = new List<float>();
                        float lastTimestamp = 0;
    
                        foreach (var (stream, chunkIndex) in sortedChunks)
                        {
                            var r = stream.Result;
                            mergedText.Append(r.Text);
    
                            if (r.Tokens != null)
                            {
                                mergedTokens.AddRange(r.Tokens);
                            }
    
                            if (r.Timestamps != null && r.Timestamps.Length > 0)
                            {
                                // Adjust timestamps based on previous chunk's last timestamp
                                for (int j = 0; j < r.Timestamps.Length; j++)
                                {
                                    mergedTimestamps.Add(r.Timestamps[j] + lastTimestamp);
                                }
                                lastTimestamp = mergedTimestamps[mergedTimestamps.Count - 1];
                            }
                        }
    
                        // For multi-chunk files, we can't create a complete OfflineRecognizerResult
                        // directly, so we'll create a fake result for display purposes
                        Console.WriteLine("--------------------");
    
                        //// 导出到lrc文件
                        Console.WriteLine(filePath);
                        Console.WriteLine("Text: {0}", mergedText.ToString());
                        Console.WriteLine("Tokens: [{0}]", string.Join(", ", mergedTokens));
                        if (mergedTimestamps.Count > 0)
                        {
                            Console.Write("Timestamps: [");
                            var sep = string.Empty;
                            for (int k = 0; k != mergedTimestamps.Count; ++k)
                            {
                                Console.Write("{0}{1}", sep, mergedTimestamps[k].ToString("0.00"));
                                sep = ", ";
                            }
                            Console.WriteLine("]");
    
    
    
                            Console.WriteLine("(Merged result - timestamps may not be fully accurate)");
                            Console.WriteLine(mergedText.ToString());
    
                        }
                        else
                        {
                            Console.WriteLine("--------------------");
                        }
    
    
                    }
                }
    
                // 释放当前批次的所有资源
                streams.Clear();
                streamFileMap.Clear();
                streamChunkMap.Clear();
                GC.Collect();
                Console.WriteLine("当前批次资源已释放");
            }
    
            // 结束批次循环
            Console.WriteLine("所有文件处理完成");
        }
    

  6. 结果处理:添加标点符号,计算时间戳,合并分割结果
     

  7. 导出结果:将识别结果导出为 TXT、LRC、SRT 格式
    单独一个ParseResult 类进行结果处理

            /// <summary>
            /// 处理识别结果,直接对标点符号进行分割,计算句子起始时间并生成带时间戳的结果
            /// </summary>
            private void ProcessResult()
            {
                if (_rawResult == null || string.IsNullOrEmpty(_rawResult.Text))
                {
                    _result = string.Empty;
                    _resultWithTimespanLrc = string.Empty;
                    _resultWithTimespanSrt = string.Empty;
                    sentence = new string[0];
                    timeRegionStructs = new TimeRegionStruct[0];
                    return;
                }
    
                // 使用原始文本
                _result = _rawResult.Text;
                
                // 直接对标点符号进行分割,放到sentence数组
                SplitTextByPunctuation();
                
                // 计算每个sentence的起始时间,放到timeRegionStructs数组
                CalculateSentenceStartTimes();
                
                // 生成带时间戳的结果:[时间戳] 句子内容
                GenerateFormattedResultWithTimespan();
                
                // 生成SRT格式的结果
                GenerateResultWithTimespanSrt();
            }
    


     

四、高级功能说明

视频转音频

程序会自动检测输入的视频文件(MP4、AVI、MKV、MOV等格式),并使用 FFMpegCore 将其转换为16kHz、单声道的 MP3 临时文件进行处理。

大文件处理

对于超过20分钟的音频文件,程序会自动将其分割成多个15分钟的片段进行处理,最后合并识别结果。分割过程会记录每个片段的时间戳信息,合并结果时会自动调整时间戳。

内存优化

程序采用批处理和流处理的方式处理文件,并定期进行垃圾回收,有效控制内存占用,适用于处理大量文件或大型音频/视频文件。

五 、后续可能方向

本来就是写着给小伙伴的工具。也许后续会加其他的更好用的模型。看大家反馈情况吧。大家觉得有用先给个star . 有好的建议,可以写个评论。

重要:如果有愿意维护代码的小伙伴,记得一定要联系我 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值