Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일

C# / Whisper 모델 - 동영상의 음성을 인식해 자동으로 SRT 자막 파일을 생성

테스트를 위해 우선 동영상 파일 먼저 구해 볼까요? ^^

"C# - Youtube 동영상 다운로드 (YoutubeExplode 패키지)" 글에서 "https://youtu.be/90HFIm2Reqk" 동영상을 다운로드하면 비디오를 담은 mp4, 오디오를 담은 webm 파일과 그 2개를 합친 "...-final.mp4" 파일이 생성됩니다.

보통은 (video + audio가 모두 있는) mp4 파일만 있을 텐데요, 그런 경우에는 FFMpegCore를 사용해 mp4로부터 오디오를 분리해 내는 것부터 시작하면 됩니다.

// Install-Package FFMpegCore
using FFMpegCore;
string mp4FilePath = @"C:\temp\test_net_conf.mp4";
string mp3FilePath = @"C:\temp\test_net_conf.mp3"; // mp3 확장자 필수!

// ffmpeg -hide_banner -i test.webm -vn test.mp3
if (FFMpeg.ExtractAudio(mp4FilePath, mp3FilePath) == false)
{
    Console.WriteLine("invalid mp4 file");
    return;
}

/*
C:\temp> ffprobe -hide_banner test_net_conf.mp3
Input #0, mp3, from 'test_net_conf.mp3':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf58.45.100
  Duration: 00:40:59.57, start: 0.023021, bitrate: 128 kb/s
  Stream #0:0: Audio: mp3, 48000 Hz, stereo, fltp, 128 kb/s
    Metadata:
      encoder         : Lavc58.91
*/

이렇게 생성한 mp3 파일의 Sample Rate는 다양하겠지만, 위의 경우에는 48kHz로 나오는데요, 아쉽게도 Whisper 모델은 16kHz 규격만 처리할 수 있기 때문에 포맷을 변환해 주어야 합니다. 마침 NAudio에서 이런 변환 기능도 제공하므로,

examples/NAudioResampleWav/Program.cs
; https://github.com/sandrohanea/whisper.net/blob/main/examples/NAudioResampleWav/Program.cs

아래와 같이 mp3 파일을 전처리할 수 있습니다.

// Install-Package NAudio
using NAudio.Wave;
using var fileStream = File.OpenRead(mp3FilePath);
using var wavStream = new MemoryStream();

using var reader = new Mp3FileReader(fileStream);
var resampler = new WdlResamplingSampleProvider(reader.ToSampleProvider(), 16000);
WaveFileWriter.WriteWavFileToStream(wavStream, resampler.ToWaveProvider16());

wavStream.Seek(0, SeekOrigin.Begin);

자, 그럼 MemoryStream에 담긴 내용을 Whisper 모델에 넘겨주면 되는데요, 아무래도 GgmlType.Base 모델은 빠르긴 해도 정확도가 너무 떨어지므로, GgmlType.LargeV1 모델을 사용해 다음과 같은 식으로 음성을 텍스트로 변환할 수 있습니다.

// Install-Package Whisper.net.AllRuntimes

var ggmlType = GgmlType.LargeV1;
var modelFileName = "ggml-large-v1.bin";

if (!File.Exists(modelFileName))
{
    await DownloadModel(modelFileName, ggmlType);
}

using var whisperFactory = WhisperFactory.FromPath(modelFileName);

// This section creates the processor object which is used to process the audio file, it uses language `auto` to detect the language of the audio file.
using var processor = whisperFactory.CreateBuilder()
    .WithLanguage("auto")
    .Build();

// This section processes the audio file and prints the results (start time, end time and text) to the console.
await foreach (var result in processor.ProcessAsync(wavStream))
{
    Console.WriteLine($"{result.Start}->{result.End}: {result.Text}");
}

/* 출력 결과
00:00:00->00:00:06.9200000:  [모든 이야기는 다음 주에 만나요]
00:00:06.9200000->00:00:09.7200000:  [모든 이야기는 다음 주에 만나요]
00:00:09.7200000->00:00:14.1200000:  네, 시작하도록 하겠습니다.
00:00:14.1200000->00:00:17.8400000:  저는 이번 세션 발표를 맡은
...[생략]...
00:40:18.5200000->00:40:22.5200000:  이 패턴에 친숙해지시는 걸 추천드린다고 말씀드리고 싶습니다.
00:40:22.5200000->00:40:34.5200000:  제 발표는 여기까지였고요. 궁금하신 점이 있거나 하시면 이 링크드인, 이렇게 질문해 보세요라고 얘기를 하면 보통 질문을 안 하시더라고요.
00:40:34.5200000->00:40:44.5200000:  링크드인 주소가 있으니까 통해서 연락 주시면 제가 답해 드릴 수 있는 범위에서 최선을 다해서 답을 해 드리도록 하겠습니다.
00:40:44.5200000->00:40:46.5200000:  네, 고맙습니다.
*/

출력 예시를 보면, 훌륭하게도 ^^ 음성이 인식된 시점의 시작 시간과 종료 시간이 함께 표시되는 것을 알 수 있습니다. 즉, 자막 파일을 위한 기본적인 정보가 모두 갖추어진 셈입니다.

이제 남은 것은 자막 파일의 포맷에 맞게 쓰기만 하면 되는데요, 여러 자막 포맷이 있지만 여기서는 SRT 포맷을 예로 들어,

SRT 파일 구조 
; https://docs.fileformat.com/ko/video/srt/

/*
1
00:05:00,400 --> 00:05:15,300
This is an example of
a subtitle.

2
00:05:16,400 --> 00:05:25,300
This is an example of
a subtitle - 2nd subtitle.
*/

간단한 도우미 클래스를 만든 후,

using System.Text;

public class SRTWriter : IAsyncDisposable
{
    FileStream _fs;
    int _index;

    public SRTWriter(string filePath)
    {
        _index = 0;
        _fs = File.OpenWrite(filePath);
    }

    public void Write(TimeSpan startTime, TimeSpan endTime, string text)
    {
        _index++;

        string timeLine = $"{_index}\n{startTime:hh\\:mm\\:ss\\,fff} --> {endTime:hh\\:mm\\:ss\\,fff}\n";
        byte [] buffer = Encoding.UTF8.GetBytes(timeLine);
        _fs.Write(buffer);

        string subTitle = $"{text}\n\n";
        buffer = Encoding.UTF8.GetBytes(subTitle);
        _fs.Write(buffer);
    }

    public ValueTask DisposeAsync()
    {
        _fs.DisposeAsync();
        return ValueTask.CompletedTask;
    }
}

Whisper.NET의 출력을 그대로 보내주면 됩니다.

// ...[생략]...

string srtPath = Path.ChangeExtension(mp4FilePath, ".srt");

File.WriteAllText(srtPath, string.Empty);

await using SRTWriter writer = new(srtPath);
await foreach (var result in processor.ProcessAsync(wavStream))
{
    Console.WriteLine($"{result.Start}->{result.End}: {result.Text}");
    writer.Write(result.Start, result.End, result.Text);
}

끝입니다. ^^ 실행해 보면, 가령 입력 파일이 test_net_conf.mp4 입력 파일이었다면 test_net_conf.srt 파일이 생성될 텐데요, 동영상 재생기를 통해 mp4 파일을 실행하면 다음과 같이 자막이 표시되는 것을 확인할 수 있습니다.

srt_from_video_1.png

게다가 Whisper 모델이 한국어뿐만 아니라 영어, 일본어 등을 지원하기 때문에, whisperFactory의 WithLanguage에 어떤 인자를 전달하느냐에 따라,

await using var processor = whisperFactory.CreateBuilder()
    .WithLanguage("ja") // 일본어로 번역
    .Build();

다국어 자막 파일을 (위의 경우에는 일본어로) 생성하는 것도 가능합니다.

srt_from_video_2.png

현재 GgmlType.LargeV1 모델 정도로도 꽤 괜찮은 인식률을 보여주기 때문에 이러한 방식으로 다국어 지원을 하는 것은 순전히 여러분이 소유한 GPU 성능에 달려 있다고 할 수 있습니다.

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




참고로, 아래의 동영상을 보면,

How I Transcribe Audio Locally with Whisper and .NET
; https://www.youtube.com/watch?v=H9kr7a78v44&ab_channel=StevanFreeborn

스티븐 프리본(Stevan Freeborn)이라는 분이 Whisper 모델을 사용해 동영상의 음성을 인식해 자막 파일을 생성하는 방법을 아주 상세히 설명해 주고 있습니다. 단지 그 영상의 소스 코드에서는 파일을 2분씩 끊어서 Whisper에 전달하고 있는데, 사실 그럴 필요가 없습니다. 왜냐하면 Azure OpenAI 서비스의 Whisper 모델에 대해서만 25MB 제약이 있는 것이고, 로컬에서 Whisper 모델을 사용하는 경우에는 그런 제약이 없기 때문입니다. 게다가 단순히 2분 단위로 끊으면 음성의 중간에 끊어질 수도 있기 때문에 가능하다면 단어 단위로 끊는 것이 더 좋습니다. ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 10/12/2025]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2025-10-12 10시00분
umlx5h/LLPlayer
 - The media player for language learning, with dual subtitles, AI-generated subtitles, real-time translation, and more!
; https://github.com/umlx5h/LLPlayer
정성태

... [76]  77  78  79  80  81  82  83  84  85  86  87  88  89  90  ...
NoWriterDateCnt.TitleFile(s)
12137정성태2/9/202021207오류 유형: 592. [AhnLab] 경고 - 디버거 실행을 탐지했습니다.
12136정성태2/6/202022805Windows: 168. Windows + S(또는 Q)로 뜨는 작업 표시줄의 검색 바가 동작하지 않는 경우
12135정성태2/6/202028467개발 환경 구성: 468. Nuget 패키지의 로컬 보관 폴더를 옮기는 방법 [2]
12134정성태2/5/202025808.NET Framework: 884. eBEST XingAPI의 C# 래퍼 버전 - XingAPINet Nuget 패키지 [5]파일 다운로드1
12133정성태2/5/202023611디버깅 기술: 161. Windbg 환경에서 확인해 본 .NET 메서드 JIT 컴파일 전과 후 - 두 번째 이야기
12132정성태1/28/202026794.NET Framework: 883. C#으로 구현하는 Win32 API 후킹(예: Sleep 호출 가로채기) [1]파일 다운로드1
12131정성태1/27/202024995개발 환경 구성: 467. LocaleEmulator를 이용해 유니코드를 지원하지 않는(한글이 깨지는) 프로그램을 실행하는 방법 [1]
12130정성태1/26/202022644VS.NET IDE: 142. Visual Studio에서 windbg의 "Open Executable..."처럼 EXE를 직접 열어 디버깅을 시작하는 방법
12129정성태1/26/202029756.NET Framework: 882. C# - 키움 Open API+ 사용 시 Registry 등록 없이 KHOpenAPI.ocx 사용하는 방법 [3]
12128정성태1/26/202024167오류 유형: 591. The code execution cannot proceed because mfc100.dll was not found. Reinstalling the program may fix this problem.
12127정성태1/25/202020817.NET Framework: 881. C# DLL에서 제공하는 Win32 export 함수의 내부 동작 방식(VT Fix up Table)파일 다운로드1
12126정성태1/25/202023223.NET Framework: 880. C# - PE 파일로부터 IMAGE_COR20_HEADER 및 VTableFixups 테이블 분석파일 다운로드1
12125정성태1/24/202021871VS.NET IDE: 141. IDE0019 - Use pattern matching
12124정성태1/23/202023616VS.NET IDE: 140. IDE1006 - Naming rule violation: These words must begin with upper case characters: ...
12123정성태1/23/202024149웹: 39. Google Analytics - gtag 함수를 이용해 페이지 URL 수정 및 별도의 이벤트 생성 방법 [2]
12122정성태1/20/202021091.NET Framework: 879. C/C++의 UNREFERENCED_PARAMETER 매크로를 C#에서 우회하는 방법(IDE0060 - Remove unused parameter '...')파일 다운로드1
12121정성태1/20/202019799VS.NET IDE: 139. Visual Studio - Error List: "Could not find schema information for the ..."파일 다운로드1
12120정성태1/19/202024092.NET Framework: 878. C# DLL에서 Win32 C/C++처럼 dllexport 함수를 제공하는 방법 - 네 번째 이야기(IL 코드로 직접 구현)파일 다운로드1
12119정성태1/17/202024730디버깅 기술: 160. Windbg 확장 DLL 만들기 (3) - C#으로 만드는 방법
12118정성태1/17/202025343개발 환경 구성: 466. C# DLL에서 Win32 C/C++처럼 dllexport 함수를 제공하는 방법 - 세 번째 이야기 [1]
12117정성태1/15/202023635디버깅 기술: 159. C# - 디버깅 중인 프로세스를 강제로 다른 디버거에서 연결하는 방법파일 다운로드1
12116정성태1/15/202025445디버깅 기술: 158. Visual Studio로 디버깅 시 sos.dll 확장 명령어를 (비롯한 windbg의 다양한 기능을) 수행하는 방법
12115정성태1/14/202025212디버깅 기술: 157. C# - PEB.ProcessHeap을 이용해 디버깅 중인지 확인하는 방법파일 다운로드1
12114정성태1/13/202026011디버깅 기술: 156. C# - PDB 파일로부터 심벌(Symbol) 및 타입(Type) 정보 열거 [1]파일 다운로드3
12113정성태1/12/202026672오류 유형: 590. Visual C++ 빌드 오류 - fatal error LNK1104: cannot open file 'atls.lib' [1]
12112정성태1/12/202020741오류 유형: 589. PowerShell - 원격 Invoke-Command 실행 시 "WinRM cannot complete the operation" 오류 발생
... [76]  77  78  79  80  81  82  83  84  85  86  87  88  89  90  ...