基于 SherpaOnnx 的高效语音识别控制台应用程序,支持多种音频和视频格式的语音识别,并能将识别结果导出为多种格式
缘起:
总有人向我抱怨:“哥,你推荐的美剧学英语,我辛辛苦苦下载下来,结果没字幕,像在看默剧!”、“录音转文字居然要收一杯奶茶钱,这合理吗?”、“做自媒体想提取文案,结果各大平台把我当韭菜割……”
我一琢磨,发现现在的语音识别市场简直就像“语音劫财”:免费?没门!高效?先充VIP!要识别更准确?得花钱买!本地部署?抱歉,只做云端!定制功能?加钱!
这哪是语音识别,分明是“语音收费”啊!
“穷且益坚,不坠青云之志。”
我们这种搞技术的,怎能被这点门槛困住?于是,我决定亲自动手,打造一个语音识别工具:免费如空气,高效如闪电,准确如考官,本地运行稳如老狗,定制灵活如搭乐高。
至于为何用 C# 实现?嘿嘿,其他语言当然也能做到,甚至效率更高,但本文特地为 C# 学习者争取一点福利。
下面,干货奉上,无赘述,直接开整。
一、依赖库
开发工具: visual studio 2022 + net 8
依赖库 | 版本 | 用途 |
---|---|---|
CommandLineParser | 2.9.1 | 命令行参数解析 |
FFMpegCore | 5.2.0 | 视频转音频处理 |
NAudio | 2.2.1 | 音频文件读取和处理 |
org.k2fsa.sherpa.onnx | 1.12.9 | 语音识别核心库 |
二、下代码去
-
代码 在 gitee 上。(地址 : SpeechRecognitionConsoleApp: 本地语音识别,支持中文、英文、日文、韩文。开发语言为C#。本地模型为SenseVoice. 后续有时间,会集成其他模型。 https://gitee.com/yhlcode1/SpeechRecognition
2.模型 模型下载地址: 用迅雷或其他下载工具即可。
三 、 代码结构解释
文件名 | 主要功能 |
---|---|
SpeechRecognitionConsoleApp.cs | 程序入口,处理命令行参数,协调整体工作流程 |
AudioReader.cs | 音频文件读取和处理(支持多格式、声道转换、采样率处理) |
IAudioReader.cs | 音频读取器接口定义 |
Options.cs | 离线语音识别命令行参数选项定义实体类 |
ParseResult.cs | 识别结果处理(标点符号添加、时间戳计算、多格式导出) |
-
指定文件处理:配置指定文件夹,然后识别
-
视频转音频:自动将视频文件转换为临时音频文件
用到的技术是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},将尝试直接处理原文件"); } }
-
音频读取:使用 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(); } }
-
大文件分割:对超过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; }
-
语音识别:使用 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("所有文件处理完成"); }
-
结果处理:添加标点符号,计算时间戳,合并分割结果
-
导出结果:将识别结果导出为 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 . 有好的建议,可以写个评论。
重要:如果有愿意维护代码的小伙伴,记得一定要联系我