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

C# / NAudio - (AI 학습을 위해) 무음 구간을 반영한 오디오 파일 분할

Whisper 모델의 경우,

C# - Whisper.NET Library를 이용해 음성을 텍스트로 변환 및 번역하는 예제
; https://www.sysnet.pe.kr/2/0/14013

로컬에서 실행하는 경우에는 문제가 없지만, Azure OpenAI 서비스의 Whisper 모델을 이용하는 경우에는 25MB 파일 크기 제한이 있습니다. 그렇다면 일정 크기로 나눠야 할 텐데요, 하지만 음성 데이터의 특성상 단순히 파일 크기로 나누기보다는 무음 구간을 인지해 나누는 것이 음성 인식의 정확도를 높이는 데 도움이 됩니다.

자, 그래서 ^^ 이번에는 무음 구간을 반영한 오디오 청크 분할 방법에 대해 알아보겠습니다.




이를 위해 우선 적절한 동영상 파일이 있어야 하는데요, 마침 Youtube로부터 다운로드하는 것도 만들었으니,

C# - Youtube 동영상 다운로드 (YoutubeExplode 패키지)
; https://www.sysnet.pe.kr/2/0/14021

먼저 이것을 시간에 따라 청크로 나눠보겠습니다. NAudio 라이브러리의 경우 특정 시간만을 잘라내는 기능을 이미 제공하고 있으므로,

Using OffsetSampleProvider
; https://github.com/naudio/NAudio/blob/master/Docs/OffsetSampleProvider.md

using var reader = new AudioFileReader("test.wav");

var offset = new OffsetSampleProvider(reader);
offset.SkipOver = TimeSpan.FromSeconds(10); // 5초 후,
offset.Take = TimeSpan.FromSeconds(20); // 20초 분량만큼.

// 이 구간만을 잘라내서 out.wav로 저장.
WaveFileWriter.CreateWaveFile16(@"out.wav", offset);

특정 오디오 파일을 (예를 들어) 2분마다 끊어서 파일에 저장하는 것을 다음과 같이 구현할 수 있습니다.

using var reader = new AudioFileReader(inputPath);

var chunkDuration = TimeSpan.FromMinutes(2);
var numOfChunks = (int)Math.Ceiling(reader.TotalTime.TotalSeconds / chunkDuration.TotalSeconds);

for (int i = 0; i < numOfChunks; i++)
{
    reader.Position = 0;
    var offset = new OffsetSampleProvider(reader);
    offset.SkipOver = TimeSpan.FromSeconds(i * chunkDuration.TotalSeconds);
    offset.Take = TimeSpan.FromSeconds(chunkDuration.TotalSeconds);

    WaveFileWriter.CreateWaveFile16($"out_{i}min.wav", offset);
}




하지만 우리가 원하는 것은, 단순한 파일 크기만이 아니라 거기에 무음 구간을 반영하는 것입니다. 이를 위해서는 어쨌든 음성 데이터에 접근해야 하는데요, NAudio의 경우 샘플링된 데이터를 다음과 같은 방식으로 열거할 수 있습니다.

AudioFileReader source = new("...[오디오 파일 경로]...");

source.Position = 0;

float[] buffer = new float[1600];
long totalSamplesRead = 0;
int read;

while ((read = source.Read(buffer, 0, buffer.Length)) > 0)
{
    for (int i = 0; i < read; i++)
    {
        float value = buffer[i]; // -1.0 ~ +1.0 사이의 값
    }

    totalSamplesRead += read;
}

엄밀히 WAV 데이터는 bitsPerSample의 값 범위를 갖는데, 예를 들어 16비트라면 (-32768도 표현은 되지만) -32767 ~ +32767 사이의 값이 됩니다. 반면 NAudio가 열거한 값은 -1.0 ~ +1.0 사이의 정규화된 값이므로 이상적인 무음 값은 0.0이 연속으로 나와야 합니다.

하지만, 아날로그 성격상 오디오 데이터는 잡음이 섞일 수 있기 때문에, 가령 Abs(0.01) 이하의 진동만 있다면 무음이라는 식으로 판단해야 합니다. 그리고 한 가지 더 고려해야 할 것이, "무음의 연속"에 대한 판단입니다. 0.01 이하의 값은 샘플링된 순간에 따라 나오는 것도 가능하기 때문에 어느 정도 지속 구간을 함께 기준으로 추가해야 합니다.

이를 바탕으로 코딩을 하려고 했는데... 요즘이 어떤 세상입니까? ^^ AI가 코드 생성을 이렇게 해주는군요.

static List<TimeSpan> DetectSilencePoints(
        AudioFileReader source,
        double silenceThresholdDb = -35,
        int minSilenceDurationMs = 300,
        int analysisWindowMs = 50)
{
    // Reset reader to start
    source.Position = 0;

    int sampleRate = source.WaveFormat.SampleRate;
    int channels = source.WaveFormat.Channels;
    int samplesPerWindow = Math.Max(1, sampleRate * analysisWindowMs / 1000) * channels;

    float[] buffer = new float[samplesPerWindow];
    var silenceRuns = new List<(TimeSpan start, TimeSpan end)>();

    bool inSilence = false;
    TimeSpan runStart = TimeSpan.Zero;

    long totalSamplesRead = 0;
    int read;

    while ((read = source.Read(buffer, 0, buffer.Length)) > 0)
    {
        int frames = read / channels;
        if (frames <= 0) continue;

        // Peak of this window
        float peak = 0f;
        for (int i = 0; i < read; i++)
        {
            peak = Math.Max(peak, Math.Abs(buffer[i]));
        }

        // Convert to dBFS; clamp to avoid log(0)
        double db = (peak <= 1e-9) ? -120.0 : 20.0 * Math.Log10(peak);

        TimeSpan windowStart = SamplesToTime(totalSamplesRead, sampleRate, channels);
        TimeSpan windowEnd = SamplesToTime(totalSamplesRead + read, sampleRate, channels);

        bool isSilent = db <= silenceThresholdDb;

        if (isSilent && !inSilence)
        {
            inSilence = true;
            runStart = windowStart;
        }
        else if (!isSilent && inSilence)
        {
            inSilence = false;
            var dur = windowStart - runStart;
            if (dur.TotalMilliseconds >= minSilenceDurationMs)
                silenceRuns.Add((runStart, windowStart));
        }

        totalSamplesRead += read;
    }

    // End boundary
    if (inSilence)
    {
        var end = SamplesToTime(totalSamplesRead, sampleRate, channels);
        var dur = end - runStart;
        if (dur.TotalMilliseconds >= minSilenceDurationMs)
            silenceRuns.Add((runStart, end));
    }

    // Use midpoints of runs as candidate "clean" cut points.
    var points = silenceRuns.Select(run => run.start + TimeSpan.FromTicks((run.end - run.start).Ticks / 2)).ToList();

    Console.WriteLine($"Detected {points.Count} silence candidates (≥ {minSilenceDurationMs} ms @ {silenceThresholdDb} dBFS).");
    return points;
}

그러니까, 총 300ms 구간 동안 -35dBFS 이하의 값이 연속으로 나오면 그것을 무음으로 판단하고 있습니다. 개인적으로 "dBFS"라는 단위를 처음 봤는데요, 코드에서처럼 정규화된 PCM 값에 아래의 공식을 적용해 계산할 수 있다고 합니다.

dBFS = 20 * log10(정규화된 값)

그런 의미에서 봤을 때, -35dBFS는 대략 0.0177 정도의 진폭에 해당하기 때문에 저 계산을 그냥 빼고 진폭 값을 직접 비교해도 무방합니다.

static List<TimeSpan> DetectSilencePoints(
        AudioFileReader source,
        double silenceThreshold = 0.0177f,
        int minSilenceDurationMs = 300,
        int analysisWindowMs = 50)
{
    // ...[생략]...

    while ((read = source.Read(buffer, 0, buffer.Length)) > 0)
    {
        // ...[생략]...

        // Peak of this window
        float peak = 0f;
        for (int i = 0; i < read; i++)
        {
            peak = Math.Max(peak, Math.Abs(buffer[i]));
        }

        // 0.1 ==> 약 -40dBFS
        // 0.0177 ==> 약 -35.04dBFS

        // 주석 처리
        // double db = peak; // (peak <= 1e-9) ? -120.0 : 20.0 * Math.Log10(peak);

        TimeSpan windowStart = SamplesToTime(totalSamplesRead, sampleRate, channels);
        TimeSpan windowEnd = SamplesToTime(totalSamplesRead + read, sampleRate, channels);

        bool isSilent = peak <= silenceThreshold;

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

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

    return points;
}




자, 이제 마지막으로 무음 구간을 반영해 청크로 나눠 볼 텐데요, 가령 2분 단위로 끊되 앞/뒤로 무음 구간이 있다면 그걸 반영해 끊는 것입니다. 이것도 그냥 AI한테 만들어 달라고 하니 다음과 같이 ^^ 잘 만들어줍니다.

/// <summary>
/// Compute final cut points: start(0), snapped cuts near each target interval, end(total).
/// </summary>
static List<TimeSpan> ComputeCutPoints(
    TimeSpan total,
    TimeSpan targetInterval,
    List<TimeSpan> silenceCandidates,
    TimeSpan searchWindow,
    TimeSpan minGapAfterCut)
{
    var cuts = new List<TimeSpan> { TimeSpan.Zero };

    // Generate target times: 2min, 4min, ... < total
    var targets = new List<TimeSpan>();
    for (var t = targetInterval; t < total; t += targetInterval)
        targets.Add(t);

    TimeSpan lastCut = TimeSpan.Zero;

    foreach (var t in targets)
    {
        var min = t - searchWindow;
        var max = t + searchWindow;

        // Find nearest silence within window that keeps reasonable spacing
        var candidate = silenceCandidates
            .Where(s => s >= min && s <= max && (s - lastCut) >= minGapAfterCut)
            .OrderBy(s => Math.Abs((s - t).Ticks))
            .FirstOrDefault();

        TimeSpan chosen;
        if (candidate == default)
        {
            chosen = (t - lastCut) >= minGapAfterCut ? t : lastCut; // fallback to exact t if spacing ok
        }
        else
        {
            chosen = candidate;
        }

        if (chosen > lastCut && chosen < total)
        {
            cuts.Add(chosen);
            lastCut = chosen;
        }
    }

    if (cuts.Last() != total)
        cuts.Add(total);

    // Deduplicate / sort just in case
    cuts = cuts.Distinct().OrderBy(ts => ts).ToList();

    Console.WriteLine("Cut map:");
    for (int i = 0; i < cuts.Count; i++)
        Console.WriteLine($"  [{i}] {cuts[i]}");

    return cuts;
}

딱히 어려운 메서드는 아니므로 설명은 생략하겠습니다. ^^ 여기까지의 코드를 종합하면,

string inputPath = @"C:\media_sample\test.wav";

using var reader = new AudioFileReader(inputPath);

// 1) Detect silence points.
List<TimeSpan> silencePoints = DetectSilencePoints(reader, silenceThresholdDb: 0.0177, minSilenceDurationMs: 300, analysisWindowMs: 50);

double segmentSec = 120;
double searchWindowSec = 15;

// 2) Build cut list: 0, cuts near 2min multiples (snapped to silence), end
List<TimeSpan> cutPoints = ComputeCutPoints(
    total: reader.TotalTime,
    targetInterval: TimeSpan.FromSeconds(segmentSec),
    silenceCandidates: silencePoints,
    searchWindow: TimeSpan.FromSeconds(searchWindowSec),
    minGapAfterCut: TimeSpan.FromSeconds(5) // avoid super-nearby duplicate cuts
);

우리가 원하는 오디오 구간을 반영한 TimeSpan 목록을 얻게 됩니다.




끝이군요, ^^ TimeSpan 목록을 얻었으니 이제 그에 맞게 오디오 청크를 (처음에 설명했던 OffsetSampleProvider를 이용해) 잘라내는 것으로 마무리할 수 있습니다.

string outputDir = Path.Combine(Environment.CurrentDirectory, "output");
Directory.CreateDirectory(outputDir);
ExportSegments(inputPath, outputDir, cutPoints, reader);

static void ExportSegments(string inputPath, string outputDir, List<TimeSpan> cuts, AudioFileReader reader)
{
    string baseName = Path.GetFileNameWithoutExtension(inputPath);

    for (int i = 0; i + 1 < cuts.Count; i++)
    {
        var start = cuts[i];
        var end = cuts[i + 1];
        var len = end - start;
        if (len <= TimeSpan.Zero) continue;

        reader.Position = 0;
        var offset = new OffsetSampleProvider(reader)
        {
            SkipOver = start,
            Take = len,
        };

        string outPath = Path.Combine(outputDir, $"{baseName}_part_{i + 1:00}_{FormatTime(start)}~{FormatTime(end)}.wav");
        WaveFileWriter.CreateWaveFile16(outPath, offset); // writes 16-bit WAV
        Console.WriteLine($"Wrote: {outPath} ({len})");
    }
}

제가 테스트한 "https://youtu.be/90HFIm2Reqk" 40여 분 정도의 동영상은 다음과 같이 21개의 청크로 나누어졌고,

Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_01_00-00-00~00-01-58.wav (00:01:58.7000000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_02_00-01-58~00-03-56.wav (00:01:57.7000000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_03_00-03-56~00-06-06.wav (00:02:09.7750000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_04_00-06-06~00-07-57.wav (00:01:51.6500000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_05_00-07-57~00-10-00.wav (00:02:02.8250000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_06_00-10-00~00-12-12.wav (00:02:11.5500000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_07_00-12-12~00-14-01.wav (00:01:48.8500000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_08_00-14-01~00-15-59.wav (00:01:57.9500000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_09_00-15-59~00-18-09.wav (00:02:10.5250000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_10_00-18-09~00-19-54.wav (00:01:45.2250000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_11_00-19-54~00-22-00.wav (00:02:05.6500000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_12_00-22-00~00-24-06.wav (00:02:06.2250000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_13_00-24-06~00-25-57.wav (00:01:50.5750000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_14_00-25-57~00-28-00.wav (00:02:03.0250000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_15_00-28-00~00-30-07.wav (00:02:07.0500000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_16_00-30-07~00-32-05.wav (00:01:57.8500000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_17_00-32-05~00-34-02.wav (00:01:57.8500000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_18_00-34-02~00-36-08.wav (00:02:05.1750000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_19_00-36-08~00-38-00.wav (00:01:51.8500000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_20_00-38-00~00-39-55.wav (00:01:55.5250000)
Wrote: c:\temp\ConsoleApp2\AnyCPU\Debug\output\test_part_21_00-39-55~00-40-59.wav (00:01:03.9950000)

몇몇 음성 파일의 끝과 그다음 시작 부분을 들어보니 무음 구간을 잘 반영해 끊어진 것을 알 수 있었습니다. ^^

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




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







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

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

비밀번호

댓글 작성자
 




1  2  3  4  5  6  [7]  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13873정성태1/23/20256603디버깅 기술: 217. WinDbg - PCI 장치 열거파일 다운로드1
13872정성태1/23/20256260오류 유형: 944. WinDbg - 원격 커널 디버깅이 연결은 되지만 Break (Ctrl + Break) 키를 눌러도 멈추지 않는 현상
13871정성태1/22/20258077Windows: 278. Windows - 윈도우를 다른 모니터 화면으로 이동시키는 단축키 (Window + Shift + 화살표)
13870정성태1/18/20259073개발 환경 구성: 741. WinDbg - 네트워크 커널 디버깅이 가능한 NIC 카드 지원 확대
13869정성태1/18/20257494개발 환경 구성: 740. WinDbg - _NT_SYMBOL_PATH 환경 변수에 설정한 경로로 심벌 파일을 다운로드하지 않는 경우
13868정성태1/17/20257664Windows: 277. Hyper-V - Windows 11 VM의 Enhanced Session 모드로 로그인을 할 수 없는 문제
13867정성태1/17/20259246오류 유형: 943. Hyper-V에 Windows 11 설치 시 "This PC doesn't currently meet Windows 11 system requirements" 오류
13866정성태1/16/202510557개발 환경 구성: 739. Windows 10부터 바뀐 device driver 서명 방법
13865정성태1/15/20258682오류 유형: 942. C# - .NET Framework 4.5.2 이하의 버전에서 HttpWebRequest로 https 호출 시 "System.Net.WebException" 예외 발생
13864정성태1/15/20258779Linux: 114. eBPF를 위해 필요한 SELinux 보안 정책
13863정성태1/14/20256749Linux: 113. Linux - 프로세스를 위한 전용 SELinux 보안 문맥 지정
13862정성태1/13/20257802Linux: 112. Linux - 데몬을 위한 SELinux 보안 정책 설정
13861정성태1/11/20258327Windows: 276. 명령행에서 원격 서비스를 동기/비동기로 시작/중지
13860정성태1/10/20257270디버깅 기술: 216. WinDbg - 2가지 유형의 식 평가 방법(MASM, C++)
13859정성태1/9/20258862디버깅 기술: 215. Windbg - syscall 이후 실행되는 KiSystemCall64 함수 및 SSDT 디버깅
13858정성태1/8/20259403개발 환경 구성: 738. PowerShell - 원격 호출 시 "powershell.exe"가 아닌 "pwsh.exe" 환경으로 명령어를 실행하는 방법
13857정성태1/7/20259375C/C++: 187. Golang - 콘솔 응용 프로그램을 Linux 데몬 서비스를 지원하도록 변경파일 다운로드1
13856정성태1/6/20257090디버깅 기술: 214. Windbg - syscall 단계까지의 Win32 API 호출 (예: Sleep)
13855정성태12/28/20249719오류 유형: 941. Golang - os.StartProcess() 사용 시 오류 정리
13854정성태12/27/20249364C/C++: 186. Golang - 콘솔 응용 프로그램을 NT 서비스를 지원하도록 변경파일 다운로드1
13853정성태12/26/20247529디버깅 기술: 213. Windbg - swapgs 명령어와 (Ring 0 커널 모드의) FS, GS Segment 레지스터
13852정성태12/25/202410334디버깅 기술: 212. Windbg - (Ring 3 사용자 모드의) FS, GS Segment 레지스터파일 다운로드1
13851정성태12/23/20247855디버깅 기술: 211. Windbg - 커널 모드 디버깅 상태에서 사용자 프로그램을 디버깅하는 방법
13850정성태12/23/202410162오류 유형: 940. "Application Information" 서비스를 중지한 경우, "This file does not have an app associated with it for performing this action."
13849정성태12/20/20249892디버깅 기술: 210. Windbg - 논리(가상) 주소를 Segmentation을 거쳐 선형 주소로 변경
13848정성태12/18/20249098디버깅 기술: 209. Windbg로 알아보는 Prototype PTE파일 다운로드2
1  2  3  4  5  6  [7]  8  9  10  11  12  13  14  15  ...