Skip to content

Instantly share code, notes, and snippets.

@zett42
Last active March 8, 2023 18:17
Show Gist options
  • Save zett42/3e04912429348874edfcf516a8b3a22f to your computer and use it in GitHub Desktop.
Save zett42/3e04912429348874edfcf516a8b3a22f to your computer and use it in GitHub Desktop.
Split an audio or video file into multiple audio files based on a time stamp file
<#
.SYNOPSIS
Split an audio or video file into multiple audio files based on a time stamp file.
.DESCRIPTION
This script uses FFmpeg to extract parts of an audio or video file into separate audio files based on the time stamps in the time stamp file.
The function also supports creating a playlist (m3u8) for the extracted audio tracks.
.PARAMETER Path
The path to the input audio or video file.
.PARAMETER TimestampPath
The path to the time stamp file. Defaults to the name of the input file with extension changed to '.txt'.
The time stamp file should contain a list of time stamps (hh:mm:ss, optionally with fractional seconds) and titles, separated by space or tab.
Each line represents the start time and title of a separate audio track to be extracted.
Example:
00:00:00 My first track
00:01:23 Another track
00:05:42.500 Yet another track
.PARAMETER SkipIndex
Define indices of tracks from the timestamp file to skip in the output, starting at 1 for the first track.
.PARAMETER UseInputIndex
If this switch is passed, output file names will use the index from the timestamp file, even if tracks are skipped
using -SkipIndex. In this case, output index will be non-contiguous.
By default output index always starts at 1 and skipped tracks won't count for output index, so output index is contiguous.
.PARAMETER OutIndexStart
Start value for output index. If -UseInputIndex is passed, OutIndexStart will be ignored.
.PARAMETER Destination
The directory where the extracted audio parts should be saved. The directory will be created if it doesn't already exist.
The default directory is the path of the input file without extension.
.PARAMETER Extension
The file extension for the extracted audio parts.
By default the audio format will be determined from the audio stream of the input file.
AAC streams will be wrapped in M4A container for better seekability.
.PARAMETER TitleFormat
The format of the title for the extracted audio parts. The title format may include two placeholders:
'{0}' for the index of the audio part, and '{1}' for the title from the time stamp file.
The default format is '{0:d2} - {1}', which writes a two-digit index, with leading zero if necessary.
.PARAMETER CreatePlaylist
Indicates whether a playlist (m3u8) should be created for the extracted audio parts.
If parameter -PlaylistPath is specified, the playlist will be saved with the given path.
Otherwise it will be saved in the output directory, using the name "playlist.m3u8".
.PARAMETER PlaylistPath
The path of the playlist file to be created for the extracted audio parts.
If you pass -PlaylistPath, you can omit -CreatePlaylist.
.PARAMETER HideProgress
Indicates whether the progress of the extraction should be displayed.
By default progress is shown.
.EXAMPLE
# A minimal command-line to split the given audio file into multiple files and create a playlist.
# The timestamp file is expected to have the same base name as the input file, with extension '.txt', i. e. 'myaudio.txt'.
# The output files will be saved in a sub folder named 'myaudio'.
Split-AudioTracks -Path 'myaudio.mp3' -CreatePlaylist
.EXAMPLE
# Split the audio track of the given video file into multiple audio files, skipping tracks 0, 3 and 7.
Split-AudioTracks -Path 'myvideo.mp4' -TimestampPath 'timestamps.txt' -SkipIndex 0,3,7 -Destination 'output' -TitleFormat 'Track {0:d3} - {1}' -PlaylistPath 'MyPlaylist.m3u8'
.NOTES
This function requires FFmpeg to be installed and its installation directory added to the PATH environment variable.
The function supports common parameters, including the following et al.:
-ErrorAction Specify the error handling mode. Pass 'Stop' to stop at first error. Otherwise the script tries to extract as many audio tracks as possible.
-Verbose Output detailed messages about what the script is doing.
-Debug Show ffmpeg console output.
-WhatIf Show what the function would do, without taking any actions.
.LINK
https://gist.github.com/zett42/3e04912429348874edfcf516a8b3a22f
#>
#requires -Version 7
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string] $Path,
[string] $Destination,
[string] $TimestampPath,
[int[]] $SkipIndex,
[switch] $UseInputIndex,
[int] $OutIndexStart = 1,
[string] $Extension,
[string] $TitleFormat = '{0:d2} - {1}',
[switch] $CreatePlaylist,
[string] $PlaylistPath,
[switch] $HideProgress
)
#------------------------------------------------------------------------------------------------------------------------------------------
Function Main {
if( $HideProgress ) { $ProgressPreference = 'SilentlyContinue' }
if( -not (Get-Command ffmpeg -EA Ignore) -or -not (Get-Command ffprobe -EA Ignore) ) {
throw [System.Management.Automation.ErrorRecord]::new(
'The "ffmpeg" and/or "ffprobe" command could not be found. Make sure FFmpeg (https://ffmpeg.org) is installed and its installation directory added to the PATH environment variable.',
'FFmpegNotFound',
[Management.Automation.ErrorCategory]::ObjectNotFound, $null
)
}
# Convert PSPath to filesystem path (to support paths that are located on a PowerShell drive)
$Path = Convert-Path -LiteralPath $Path
if( -not $TimestampPath ) {
$TimestampPath = [IO.Path]::ChangeExtension( $Path, '.txt' )
}
# Parse the timestamp file
$tracks = Get-Content $TimestampPath | ConvertFrom-TimestampData
if( -not $Extension ) {
# Determine format of first audio stream
$Extension = ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=nokey=1:noprint_wrappers=1 $Path
if( 0 -ne $LASTEXITCODE -or -not $Extension ) {
throw [System.Management.Automation.ErrorRecord]::new(
"Could not determine format of audio stream from '$Path'.`nYou may force an audio format by using parameter -OutputExt.",
'AudioFormatUnknown',
[Management.Automation.ErrorCategory]::InvalidData, $null
)
}
if( $Extension -eq 'aac' ) {
$Extension = 'm4a' # Raw AAC files are not seekable in most players, which is fixed by wrapping them into an M4A container.
}
}
if( -not $Destination ) {
# Remove extension. NullString is required because PS passes $null as an empty string!
$Destination = [IO.Path]::ChangeExtension( $Path, [NullString]::Value )
}
# Create destination and get its resolved path (unless -WhatIf argument is given, then this pipeline won't run)
New-Item $Destination -ItemType Directory -Force | ForEach-Object { $Destination = $_.FullName }
if( $CreatePlaylist -or $PlaylistPath ) {
if( -not $PlaylistPath ) {
$PlaylistPath = Join-Path $Destination 'playlist.m3u8'
}
elseif( $PlaylistPath -notlike '*.m3u8' ) {
throw [System.Management.Automation.ErrorRecord]::new(
'Argument for -PlaylistPath must specify a file path with .m3u8 extension.',
'InvalidPlaylistFormat',
[Management.Automation.ErrorCategory]::InvalidArgument, $null
)
}
$PlaylistPath = (New-Item $PlaylistPath -Force).FullName
}
$inIndex = 0
$outIndex = $OutIndexStart
$processedCount = 0
$totalCount = $tracks.Count
if( $SkipIndex ) {
$totalCount -= $SkipIndex.Count
}
$progressActivity = "Splitting into $totalCount audio files"
foreach( $track in $tracks ) {
++$inIndex
if( $SkipIndex -and $SkipIndex -contains $inIndex ) {
continue
}
$durationArg = @{}
if( $track.duration ) {
$durationArg = @{ Duration = $track.duration }
}
try {
if( $UseInputIndex ) { $outIndex = $inIndex }
ExtractAudioTrack -startTime $track.startTime @durationArg -index $outIndex -processed $processedCount -total $totalCount -title $track.Title
}
catch {
# Temporarily set $ErrorActionPreference to the caller's preference. If they set it to 'Stop' or pass argument -ErrorAction 'Stop',
# the script should terminate at first error. Otherwise try to extract as many tracks as possible.
& {
$ErrorActionPreference = $oldErrorActionPreference
$PSCmdlet.WriteError( $_ )
}
}
++$processedCount
++$outIndex
}
Write-Progress -Activity $progressActivity -Completed
}
#------------------------------------------------------------------------------------------------------------------------------------------
Function ConvertFrom-TimestampData {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string[]] $InputObject
)
begin {
$result = [Collections.ArrayList]::new()
}
process {
foreach( $line in $InputObject ) {
if( $line = $line.Trim() ) {
$startTime, $title = $line -split '\s+', 2
$null = $result.Add( [pscustomobject]@{
StartTime = [Timespan] $startTime
Duration = $null
Title = $title.Trim()
})
}
}
}
end {
if( $result.Count -ge 2 ) {
foreach( $i in 0..($result.Count - 2) ) {
$result[ $i ].Duration = $result[ $i + 1 ].StartTime - $result[ $i ].StartTime
}
}
$result
}
}
#------------------------------------------------------------------------------------------------------------------------------------------
Function ExtractAudioTrack( [Timespan] $startTime, [Timespan] $duration, [int] $index, [int] $processed, [int] $total, [string] $title ) {
$outFileName = ($TitleFormat -f $index, $title) + ".$Extension"
$outFilePath = Join-Path $Destination $outFileName
$ffmpegArgs = @(
'-y' # Overwrite any existing file.
'-hide_banner'
'-i', $Path,
'-ss', $startTime.ToString('c') # Force format 'hh:mm:ss', ignoring culture.
if( $duration ) { '-t', $duration.TotalSeconds }
'-acodec', 'copy'
'-vn'
$outFilePath
)
$joinedArgs = $ffmpegArgs -replace '.*\s.*', '"$0"' -join ' '
Write-Debug "ffmpeg $joinedArgs"
if( $PSCmdlet.ShouldProcess( $outFilePath, 'Extract audio part' )) {
# Due to some weirdness we must start progress at 1 (0 shows a 100% bar!)
Write-Progress -Activity $progressActivity -Status $outFileName -PercentComplete ($processed * 99 / $total + 1)
Invoke-FFmpeg -FFmpegArgs $ffmpegArgs -Action "Extract audio file '$outFileName'" -ActionTarget $outFilePath
if( $PlaylistPath ) {
# Create relative path for the playlist entry which allows to put the playlist file in a different directory
# than the tracks. Push-Location sets the base path for Resolve-Path -Relative.
Push-Location (Split-Path $playlistPath -Parent)
try {
$relativeOutFilePath = (Resolve-Path -Relative -LiteralPath $outFilePath) -replace '^\.\\'
$relativeOutFilePath | Add-Content -Path $playlistPath -Encoding UTF8
}
finally {
Pop-Location
}
}
}
}
#------------------------------------------------------------------------------------------------------------------------------------------
Function Invoke-FFmpeg {
[CmdletBinding()]
param (
[Parameter( Mandatory )]
[string[]] $FFmpegArgs,
[Parameter( Mandatory )]
[string] $Action,
[Parameter( Mandatory )]
[string] $ActionTarget
)
$joinedArgs = $FFmpegArgs -replace '.*\s.*', '"$0"' -join ' '
Write-Debug "ffmpeg $joinedArgs"
$errorDetails = if( $VerbosePreference -eq 'Continue' ) {
# Output everything to verbose stream (no capturing).
ffmpeg @FFmpegArgs *>&1 | Write-Verbose
Write-Verbose ('-' * ($Host.UI.RawUI.BufferSize.Width - 'VERBOSE: '.Length))
}
else {
# Capture only error messages, if any.
ffmpeg -v error @FFmpegArgs *>&1
}
if( 0 -ne $LASTEXITCODE ) {
$errorMessage = "Operation failed: $Action"
if( $errorDetails ) {
$errorMessage += "`n$errorDetails"
}
throw [System.Management.Automation.ErrorRecord]::new(
$errorMessage,
'ffmpeg',
[Management.Automation.ErrorCategory]::OperationStopped,
$ActionTarget
)
}
}
#------------------------------------------------------------------------------------------------------------------------------------------
$oldErrorActionPreference = $ErrorActionPreference
# Set script-local ErrorActionPreference to 'Stop' to define our preferred way to handle errors internally, within this function.
$ErrorActionPreference = 'Stop'
try {
Main
}
catch {
# Finally we want to respect $ErrorActionPreference of the caller and -ErrorAction common parameter, so restore $ErrorActionPreference.
$ErrorActionPreference = $oldErrorActionPreference
$PSCmdlet.WriteError( $_ )
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment