Skip to content

Instantly share code, notes, and snippets.

@milnak
Last active August 15, 2024 18:07
Show Gist options
  • Save milnak/7459b156915e64adaa07350393b171fa to your computer and use it in GitHub Desktop.
Save milnak/7459b156915e64adaa07350393b171fa to your computer and use it in GitHub Desktop.
Extract audio files from CUE file: Uses ffmpeg.exe as a helper
# https://wiki.hydrogenaud.io/index.php?title=Cue_sheet
Param(
# Path to CUE file
[Parameter(Mandatory = $true)][string]$CuePath
)
function Parse-Cue {
Param([Parameter(Mandatory = $true)][string]$CuePath)
# Top level object. Includes File array.
$albumInfo = @{
Files = @()
}
$cue = Get-Content -LiteralPath $CuePath | ForEach-Object { $_.Trim() }
$parsingfile = $false
foreach ($line in $cue) {
$spaceIdx = $line.IndexOf(' ')
$key = $line.Substring(0, $spaceIdx)
$value = $line.Substring($spaceIdx + 1).Trim('"')
if ($key -eq 'REM') {
# REVIEW: Treat REM as if it wasn't a REM.
$spaceIdx = $value.IndexOf(' ')
$key = $value.Substring(0, $spaceIdx)
$value = $value.Substring($spaceIdx + 1).Trim('"')
}
if ($key -eq 'FILE') {
# REVIEW: Assume that once a FILE tag is seen, the rest is FILE metadata.
$parsingFile = $true
}
if (-not $parsingFile) {
# ALBUM metadata
if ($key -in 'DATE', 'DISCID', 'COMMENT', 'CATALOG', 'COMPOSER', 'GENRE', 'PERFORMER', 'TITLE') {
$albumInfo.Add($key, $value)
}
else {
Write-Warning "Invalid ALBUM value: $key"
}
}
else {
# Parsing FILE objects
if ($key -eq 'FILE') {
# New file
# FILE "Range.wav" WAVE
$spaceIdx = $value.LastIndexOf(' ')
$fileName = $value.Substring(0, $spaceIdx).Trim('"')
$fileType = $value.Substring($spaceIdx + 1)
$albumInfo.Files += @{
Filename = $fileName
Type = $fileType
Tracks = @()
}
}
elseif ($key -eq 'TRACK') {
# New track
# TRACK 01 AUDIO
$spaceIdx = $value.IndexOf(' ')
$trackNumber = $value.Substring(0, $spaceIdx)
$trackType = $value.Substring($spaceIdx + 1)
$numFiles = $albumInfo.Files.Count
$albumInfo.Files[$numFiles - 1].Tracks += @{
Number = [int]$trackNumber
Type = $trackType
Indices = @()
}
}
elseif ($key -in 'COMPOSER', 'FLAGS', 'TITLE', 'PERFORMER', 'ISRC') {
# TITLE "Red Rain"
# PERFORMER "Peter Gabriel"
$numFiles = $albumInfo.Files.Count
$numTracks = $albumInfo.Files[$numFiles - 1].Tracks.Count
$albumInfo.Files[$numFiles - 1].Tracks[$numTracks - 1] += @{
$key = $value
}
}
elseif ($key -eq 'INDEX') {
# INDEX 00 28:41:15
# INDEX 01 28:42:06
$spaceIdx = $value.IndexOf(' ')
$indexTime = $value.Substring($spaceIdx + 1)
$numFiles = $albumInfo.Files.Count
$numTracks = $albumInfo.Files[$numFiles - 1].Tracks.Count
$albumInfo.Files[$numFiles - 1].Tracks[$numTracks - 1].Indices += $indexTime
}
else {
Write-Warning "Invalid FILE value: $key"
}
}
}
$albumInfo
}
function Convert-MSFToHMS {
Param([string]$CueTime)
$split = $CueTime -split ':'
if ($split.Length -ne 3) {
Write-Warning "Invalid CUE time: $CueTime"
return
}
if ([int]$split[2] -ge 75) {
Write-Warning "Invalid frames: $CueTime"
return
}
# CUE time is in m:s:f. Frame is 1/75th of a second. Convert to milliseconds.
$timespan = New-TimeSpan -Minutes ([int]$split[0]) -Seconds ([int]$split[1]) -Milliseconds ([int]($split[2] / 75 * 1000))
# ffmpeg time unit format is sexagesimal (HOURS:MM:SS.MILLISECONDS, as in 01:23:45.678)
$timespan.ToString('hh\:mm\:ss\.fff')
}
function Make-LegalFilename {
Param([Parameter(Mandatory = $true)][string]$Name)
# Replace invalid characters with '_'
$regex = '[{0}]' -f [RegEx]::Escape([IO.Path]::GetInvalidFileNameChars() -join '')
$Name -replace $regex, '_'
}
function Run-FFMPEG {
Param([Parameter(Mandatory = $true)]$Arguments)
$psi = New-Object Diagnostics.ProcessStartInfo
$psi.FileName = 'ffmpeg.exe'
$psi.RedirectStandardError = $true
$psi.RedirectStandardOutput = $false
$psi.UseShellExecute = $false
$psi.Arguments = $Arguments
$psi.WorkingDirectory = ((Get-Location).Path)
$proc = New-Object System.Diagnostics.Process
$proc.StartInfo = $psi
$proc.Start() | Out-Null
# Important: ReadToEnd must come before WaitForExit!
$err = $proc.StandardError.ReadToEnd().Trim()
$proc.WaitForExit() | Out-Null
$result = $proc.ExitCode
if ($result -ne 0) {
Write-Warning "Failed ($result):"
$err
}
}
function Extract-Cue {
Param([PSCustomObject]$CueInfo)
foreach ($file in $CueInfo.Files) {
if ($file.Type -ne 'WAVE') {
Write-Warning ('Unsupported type: {0}, skipping.' -f $file.Type)
continue
}
$inputFile = $file.Filename
for ($trackNum = 0; $trackNum -lt $file.Tracks.Count; $trackNum++) {
$track = $file.Tracks[$trackNum]
if ($track.Type -ne 'AUDIO') {
Write-Warning ('Unsupported type: {0}, skipping.' -f $track.Type)
continue
}
$outputFile = Make-LegalFilename -Name ('{0:d2} {1} - {2}.flac' -f $track.Number, $track.TITLE, $track.PERFORMER)
# Print status
'[{0}/{1}] {2}' -f ($trackNum + 1), $file.Tracks.Count, $outputFile
$ssIndex = 0
if ($track.Indices.Count -ne 1) {
Write-Warning ('Track {0} has {1} indexes. Using index {2} as start.' -f ($trackNum + 1), $track.Indices.Count, ($track.Indices.Count - 1))
$ssIndex = $track.Indices.Count - 1
}
$ss = Convert-MSFToHMS -CueTime $track.Indices[$ssIndex]
$arguments = @('-n', '-hide_banner', '-nostdin', '-ss', $ss)
if ($trackNum + 1 -ne $file.Tracks.Count) {
# Not last track.
$to = Convert-MSFToHMS -CueTime $file.Tracks[$trackNum + 1].Indices[0]
$arguments += @('-to', $to)
}
$arguments += @('-i', """$inputFile""", """$outputFile""")
Run-FFMPEG -Arguments $arguments
}
}
}
if (-not (Get-Command 'ffmpeg.exe' -CommandType Application)) {
Write-Warning 'ffmpeg.exe not found or not in path.'
exit 1
}
if ((Split-Path -Path $CuePath -Leaf).Split(".")[1] -ne 'cue') {
Write-Warning "Need to provide a CUE file."
exit 1
}
if (-not (Test-Path -LiteralPath $CuePath -Type Leaf)) {
Write-Warning ('File {0} not found.' -f $CuePath)
exit 1
}
$cue = Parse-Cue -CuePath $CuePath
"Performer: $($cue.PERFORMER)"
"Title: $($cue.TITLE)"
''
# $cue | ConvertTo-Json -Depth 5
Extract-Cue -CueInfo $cue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment