Даже если вы не занимаетесь обработкой аудиозаписей, скорее всего, вам встречались аудиотреки с тишиной в начале или в конце. Иногда продолжительность такой тишины измеряется секундами, что может доставлять неудобства при прослушивании.
Продвинутые аудиоплееры при воспроизведении могут автоматически пропускать тишину в начале и в конце треков. Но иногда тишину требуется не просто пропустить, а обнаружить и измерить. Например, чтобы автоматически заполнить соответствующие поля в медиатеке iTunes или обрезать аудиофайлы, удалив начальную и конечную тишину.
Конечно, речь не идёт о том, чтобы определять границы тишины на слух. Определить, где заканчивается тишина и начинается звук, поможет ffmpeg, а автоматизировать задачу - PowerShell.
Перед тем как перейти к разбору программных инструментов и коду, предлагаю разобраться с тем, что считать тишиной и на какие участки тишины в аудиофайле стоит обращать внимание.
Что считать тишиной?
На слух тишина кажется чем-то очевидным: если мы ничего не слышим, значит, в аудиофайле тишина. Но для программы всё несколько сложнее.
Цифровая аудиозапись состоит из отсчётов звукового сигнала. Даже когда человек молчит, микрофон может записывать фоновый шум: работу электроники, вентилятора, окружающие звуки и другие источники. Поэтому участок, который мы воспринимаем как тишину, далеко не всегда является абсолютной тишиной с точки зрения программы, обрабатывающей аудиофайл.
При автоматическом анализе обычно задают порог громкости. Всё, что находится ниже этого порога, считается тишиной. Например, можно установить порог в -40 dB. Тогда сигнал с уровнем ниже -40 dB будет считаться тишиной, а более громкий сигнал - звуком.
Конечно, это значение нельзя считать универсальным. Для записи с низким уровнем фонового шума один порог может работать хорошо, а для записи с заметным фоном потребуется подобрать другой.
Кроме уровня громкости, важна и продолжительность участка тишины. Короткая пауза между словами сама по себе ещё не означает, что перед нами ненужная тишина. Поэтому при обнаружении тишины обычно задают минимальную продолжительность участка.
Например, если установить минимальную продолжительность в 0,5 секунды, более короткие паузы можно не учитывать.
Таким образом, для автоматического обнаружения тишины используются два параметра:
- уровень сигнала, ниже которого участок считается тишиной;
- минимальная продолжительность такого участка.
Какие участки тишины нас интересуют?
В аудиофайле тишина может встречаться в самых разных местах:
тишина │ звук │ тишина │ звук │ тишина
↑ ↑ ↑
Но если наша задача состоит в определении лишнего пространства вокруг записи, внутренние паузы нас не интересуют.
Нас интересуют только два участка:
тишина │████████████████████████│ тишина
↑ ↑
начало конец
То есть начальная тишина - это участок тишины, который начинается непосредственно с начала аудиофайла.
Конечная тишина - участок тишины, который заканчивается непосредственно в конце аудиофайла.
Все остальные участки тишины находятся внутри аудиоматериала. Это могут быть естественные паузы между словами, предложениями, музыкальными тактами или фрагментами записи, поэтому автоматически удалять их не надо.
Итак, задача состоит не просто в поиске тишины в аудиофайле. Нам необходимо найти участки тишины и определить, находятся ли они непосредственно у границ аудиофайла.
Обнаружение участков тишины с помощью ffmpeg
Для обнаружения тишины в аудиофайле воспользуемся фильтром silencedetect, входящим в состав ffmpeg.
ffmpeg -i input.m4a -af "silencedetect=noise=-40dB:d=0.1" -f null NUL
Обратите внимание на два параметра фильтра, о значении которых было сказано ранее:
noise=-40dB- порог громкости. Сигнал ниже этого уровня считается тишиной;d=0.1- минимальная продолжительность участка тишины в секундах.
В FFmpeg по умолчанию фильтр silencedetect использует следующие значения: порог громкости - -60 dB, минимальная продолжительность участка тишины - 2 секунды.
Поскольку у нас не стоит задача создать новый аудиофайл, результат фильтрации отправляется в NUL. В примере для GNU/Linux вместо NUL следовало бы использовать /dev/null.
При обнаружении участков тишины ffmpeg выводит сообщения примерно такого вида:
[silencedetect @ 0000024ef19cde00] silence_start: 0
[silencedetect @ 0000024ef19cde00] silence_end: 1.733175 | silence_duration: 1.733175
[silencedetect @ 0000024ef19cde00] silence_start: 149.28619
[silencedetect @ 0000024ef19cde00] silence_end: 150.603197 | silence_duration: 1.317007
[silencedetect @ 0000024ef19cde00] silence_start: 150.899728
[silencedetect @ 0000024ef19cde00] silence_end: 152.021043 | silence_duration: 1.121315
В качестве примера я использовал трек “Солёный блюз” из альбома “Гномики” Павла Кашина, загруженный из iTunes Store. Это далеко не самый удачный вариант оцифровки виниловой пластинки, официально выпущенный в цифровом виде и доступный на стриминговых платформах. Поэтому у записи есть свои особенности звучания, в частности заметный шум в паузах.
Что делать с результатом?
Фильтр silencedetect сообщает обо всех найденных участках тишины.
В моём примере первый участок (silence_start: 0) - начальная тишина, третий участок (silence_start: 150.899728) - конечная тишина. Второй участок (silence_start: 149.28619) в рамках стоящей задачи нас не интересует.
Начальная тишина
С начальной тишиной всё просто. Если первый обнаруженный участок начинается с нулевой отметки - это именно то, что нужно.
Конечная тишина
Здесь ситуация сложнее, потому что последний обнаруженный участок не обязательно соответствует концу аудиоданных. И это требует дополнительного анализа, для которого нужны не только данные фильтра silencedetect, но и продолжительность самого аудиофайла, которую можно получить при помощи ffprobe.
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 input.m4a
Разобравшись с теоретической частью, можно перейти к непосредственной реализации сценария PowerShell.
Сценарий PowerShell для определения начального и конечного участков тишины в аудиофайле
На практике многие пользователи используют встроенный в операционную систему Windows PowerShell 5.1, поэтому сценарий должен быть совместим с этой версией.
Сценарий будет использовать две утилиты из комплекта FFmpeg:
- ffmpeg - для обнаружения участков тишины;
- ffprobe - для определения продолжительности аудиофайла.
Конечно, продолжительность аудиофайла можно было получить и при помощи ffmpeg, но использование ffprobe упрощает эту задачу. Тем более что обе утилиты поставляются в составе одного комплекта.
По умолчанию подразумевается, что путь к установленному пакету FFmpeg добавлен в переменную PATH. На практике этого может не быть. Кроме того, пользователь может захотеть использовать разные версии FFmpeg. Поэтому сценарий должен позволять пользователю явно указать пути к исполняемым файлам ffmpeg и ffprobe с помощью параметров -FfmpegPath и -FfprobePath.
Само собой, сценарий должен принимать путь к аудиофайлу через обязательный параметр -InputFile. Кроме того, в нём должны быть предусмотрены уже рассмотренные параметры порога определения тишины в dB и минимальной продолжительности участка тишины в секундах - -SilenceThreshold и -SilenceDuration.
В случае успешного завершения работы, сценарий должен возвращать объект, содержащий следующие поля:
Duration - продолжительность аудиофайла;Start Time - время начала звукового содержимого аудиофайла;Start Silence Duration - продолжительность начальной тишины;End Time- время окончания звукового содержимого аудиофайла;End Silence Duration - продолжительность конечной тишины.
0 Duration
│ │
├─ Start Silence ─┬───────────────────────────┬─ End Silence ─┤
│ │
Start Time End Time
│<-- звуковое содержимое -->│
На мой взгляд, такой результат удобно использовать дальше в PowerShell - например, для передачи данных следующему этапу обработки без повторного анализа файла.
Исходя из перечисленных требований, предлагаю следующий вариант реализации сценария.
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$InputFile,
[Parameter()]
[double]$SilenceThreshold = -40,
[Parameter()]
[double]$SilenceDuration = 0.1,
[Parameter()]
[string]$FfmpegPath = 'ffmpeg',
[Parameter()]
[string]$FfprobePath = 'ffprobe'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ============================================================================
# Functions
# ============================================================================
function Resolve-Executable {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if ([System.IO.Path]::IsPathRooted($Path)) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Executable not found: $Path"
}
return (Resolve-Path -LiteralPath $Path).Path
}
$command = Get-Command $Path -CommandType Application -ErrorAction SilentlyContinue
if ($null -eq $command) {
throw "Executable not found: $Path"
}
return $command.Source
}
function Format-Time {
param(
[Parameter(Mandatory = $true)]
[double]$Seconds
)
if ($Seconds -lt 0) {
$Seconds = 0
}
$totalMicroseconds = [math]::Round(
$Seconds * 1000000,
0,
[System.MidpointRounding]::AwayFromZero
)
$hours = [math]::Floor(
$totalMicroseconds / 3600000000
)
$remaining = $totalMicroseconds % 3600000000
$minutes = [math]::Floor(
$remaining / 60000000
)
$remaining = $remaining % 60000000
$secondsPart = [math]::Floor(
$remaining / 1000000
)
$microseconds = $remaining % 1000000
return '{0:00}:{1:00}:{2:00}.{3:000000}' -f `
$hours,
$minutes,
$secondsPart,
$microseconds
}
function ConvertTo-InvariantDouble {
param(
[Parameter(Mandatory = $true)]
[string]$Value
)
[double]$result = 0
$parsed = [double]::TryParse(
$Value,
[System.Globalization.NumberStyles]::Float,
[System.Globalization.CultureInfo]::InvariantCulture,
[ref]$result
)
if (-not $parsed) {
throw "Invalid numeric value: $Value"
}
return $result
}
# ============================================================================
# Validate input
# ============================================================================
if (-not (Test-Path -LiteralPath $InputFile -PathType Leaf)) {
throw "Input file not found: $InputFile"
}
$resolvedInputFile = (Resolve-Path -LiteralPath $InputFile).Path
# ============================================================================
# Resolve executables
# ============================================================================
$ffmpeg = Resolve-Executable -Path $FfmpegPath
$ffprobe = Resolve-Executable -Path $FfprobePath
# ============================================================================
# Get duration using ffprobe
# ============================================================================
$ffprobeArguments = @(
'-v', 'error'
'-show_entries', 'format=duration'
'-of', 'default=noprint_wrappers=1:nokey=1'
$resolvedInputFile
)
$ffprobeOutput = & $ffprobe @ffprobeArguments 2>&1
if ($LASTEXITCODE -ne 0) {
throw "ffprobe failed with exit code $LASTEXITCODE."
}
$durationText = $ffprobeOutput |
ForEach-Object {
$_.ToString().Trim()
} |
Where-Object {
$_ -match '^[0-9]+(?:\.[0-9]+)?$'
} |
Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($durationText)) {
throw "Unable to determine audio duration using ffprobe."
}
$duration = ConvertTo-InvariantDouble -Value $durationText
# ============================================================================
# Run ffmpeg with silencedetect
# ============================================================================
$filter = "silencedetect=noise=${SilenceThreshold}dB:d=${SilenceDuration}"
$ffmpegArguments = @(
'-hide_banner'
'-loglevel', 'info'
'-nostats'
'-analyzeduration', '100M'
'-probesize', '100M'
'-i', $resolvedInputFile
'-vn'
'-sn'
'-dn'
'-af', $filter
'-f', 'null'
'-'
)
# ============================================================================
# Start ffmpeg
#
# We deliberately do not use:
#
# & ffmpeg ... 2>&1
#
# because Windows PowerShell 5.1 converts stderr output into ErrorRecord
# objects. FFmpeg uses stderr for normal diagnostic output and silencedetect
# results, so stderr must be treated as ordinary text.
# ============================================================================
$process = New-Object System.Diagnostics.Process
try {
$process.StartInfo = New-Object System.Diagnostics.ProcessStartInfo
$process.StartInfo.FileName = $ffmpeg
$process.StartInfo.UseShellExecute = $false
$process.StartInfo.CreateNoWindow = $true
$process.StartInfo.RedirectStandardError = $true
$process.StartInfo.RedirectStandardOutput = $true
# Windows PowerShell 5.1 does not support ProcessStartInfo.ArgumentList.
# Therefore use the traditional Arguments property.
$escapedArguments = foreach ($argument in $ffmpegArguments) {
$argument = [string]$argument
if ($argument -match '[\s"]') {
# Escape backslashes before quotes and escape embedded quotes.
$escaped = $argument `
-replace '(\\*)"', '$1$1\"' `
-replace '(\\+)$', '$1$1'
'"' + $escaped + '"'
}
else {
$argument
}
}
$process.StartInfo.Arguments = $escapedArguments -join ' '
if (-not $process.Start()) {
throw "Unable to start ffmpeg."
}
# Read both streams. FFmpeg writes silencedetect information to stderr.
#
# Both streams must be consumed before WaitForExit() to avoid a pipe
# deadlock when a large amount of diagnostic output is produced.
$ffmpegError = $process.StandardError.ReadToEnd()
$ffmpegOutput = $process.StandardOutput.ReadToEnd()
$process.WaitForExit()
$ffmpegExitCode = $process.ExitCode
}
finally {
if ($null -ne $process) {
$process.Dispose()
}
}
if ($ffmpegExitCode -ne 0) {
throw "ffmpeg failed with exit code $ffmpegExitCode.`n$ffmpegError"
}
# ============================================================================
# Parse silence intervals
# ============================================================================
$intervals = @()
$currentStart = $null
$ffmpegLines = @(
$ffmpegError -split "`r?`n" |
Where-Object {
-not [string]::IsNullOrWhiteSpace($_)
}
)
foreach ($line in $ffmpegLines) {
$text = $line.ToString()
# ------------------------------------------------------------------------
# silence_start
# ------------------------------------------------------------------------
if ($text -match 'silence_start:\s*([0-9]+(?:\.[0-9]+)?)') {
try {
$currentStart = ConvertTo-InvariantDouble -Value $matches[1]
}
catch {
$currentStart = $null
}
continue
}
# ------------------------------------------------------------------------
# silence_end
# ------------------------------------------------------------------------
if ($text -match 'silence_end:\s*([0-9]+(?:\.[0-9]+)?)') {
if ($null -eq $currentStart) {
continue
}
try {
$currentEnd = ConvertTo-InvariantDouble -Value $matches[1]
}
catch {
$currentStart = $null
continue
}
if ($currentEnd -lt $currentStart) {
$currentStart = $null
continue
}
$intervals += [PSCustomObject]@{
Start = $currentStart
End = $currentEnd
Duration = $currentEnd - $currentStart
}
$currentStart = $null
}
}
# ============================================================================
# Determine start of content
#
# Only the FIRST silence interval is considered.
#
# If:
#
# silence_start: 0
#
# then:
#
# Start Time = silence_end
# Start Silence Duration = silence_end - silence_start
#
# Otherwise there is no leading silence.
# ============================================================================
$startTime = 0.0
$startSilence = 0.0
if ($intervals.Count -gt 0) {
$firstInterval = $intervals[0]
# Allow a tiny floating-point difference from zero.
$epsilon = 0.000001
if ([math]::Abs($firstInterval.Start) -le $epsilon) {
$startTime = $firstInterval.End
$startSilence = $firstInterval.Duration
}
}
# ============================================================================
# Determine end of content
#
# Only the LAST silence interval is considered.
#
# If:
#
# silence_end == duration
#
# then:
#
# End Time = silence_start
# End Silence Duration = duration - silence_start
#
# All silence intervals in the middle of the file are ignored.
# ============================================================================
$endTime = $duration
$endSilence = 0.0
if ($intervals.Count -gt 0) {
$lastInterval = $intervals[$intervals.Count - 1]
# Allow a tiny floating-point difference from the ffprobe duration.
$epsilon = 0.000001
if ([math]::Abs($lastInterval.End - $duration) -le $epsilon) {
$endTime = $lastInterval.Start
$endSilence = $duration - $lastInterval.Start
}
}
# ============================================================================
# Output
# ============================================================================
[PSCustomObject]@{
'Duration' = Format-Time -Seconds $duration
'Start Time' = Format-Time -Seconds $startTime
'Start Silence Duration' = Format-Time -Seconds $startSilence
'End Time' = Format-Time -Seconds $endTime
'End Silence Duration' = Format-Time -Seconds $endSilence
}
Сохраните код в файл Get-AudioContentBounds.ps1, перейдите в директорию, укажите путь к аудиофайлу для анализа и выполните:
.\Get-AudioContentBounds.ps1 -InputFile .\input.mp3
Теперь у вас есть инструмент для автоматического определения начальной и конечной тишины. Дальше всё зависит от ваших целей и задач: можно анализировать аудиофайлы, автоматически удалять лишнюю тишину или использовать полученные данные для дальнейшей обработки.