Skip to content

Instantly share code, notes, and snippets.

@j6s
Last active June 11, 2020 01:08
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save j6s/096ff1e1ce7fc21887d61387f0f81f7b to your computer and use it in GitHub Desktop.
Save j6s/096ff1e1ce7fc21887d61387f0f81f7b to your computer and use it in GitHub Desktop.
merge_to_opus.rb
#!/usr/bin/env ruby
require 'digest'
#
# A script that merges multiple audio files to a single OPUS File with chapter
# markers based on the input files.
# Every input file will be one chapter in the output OPUS File.
# The ouptut file is hardcoded to be 'merged.opus'
#
# For more information about OPUS Chapter marks:
# => https://wiki.xiph.org/Chapter_Extension
#
# For more information about the ffmpeg concat filter:
# => https://trac.ffmpeg.org/wiki/Concatenate
# => https://ffmpeg.org/ffmpeg-filters.html#concat
#
# USAGE: ./merge_to_opus.rb file1.flac file2.flac file3.flac ...
# EXAMPLE: ./merge_to_opus Chapter_*.flac
# AUTHOR: Johannes Hertenstein, 2017
# LICENSE: WTFPL
#
#
# General settings.
# The output file format and options can be set here.
#
options = [
'-acodec libopus',
'-b:a 64000',
'-vbr on',
'-compression_level 10',
'-safe 0'
]
#
# Formats a time in seconds according to the OPUS chapter marks specification.
# The output will always be a string of fixed length with the following structure:
# HH:MM:SS.SSS
#
def format_time(seconds)
minutes = 0
hours = 0
while seconds >= 60
minutes += 1
seconds -= 60
end
while minutes >= 60
hours += 1
minutes -= 60
end
hours = hours.to_s.rjust(2,'0')
minutes = minutes.to_s.rjust(2,'0')
seconds = sprintf('%.3f', seconds).rjust(6, '0')
return "#{hours}:#{minutes}:#{seconds}"
end
total_length=0
chapter=1
files = ARGV
files.each do |file|
# Find out the time of the current chapter
seconds = `ffprobe -i #{file} -show_entries format=duration -v quiet -of csv="p=0"`.to_i
time = format_time(total_length)
total_length += seconds
# Extract a chapter name from the filename
name = file.split('.')[0...-1].join('.')
# format the chapter number
num = chapter.to_s.rjust(3, '0')
# Add the metadata options to ffmpegs optionlist
options.push("-metadata CHAPTER#{num}=#{time}")
options.push("-metadata CHAPTER#{num}NAME=#{name}")
chapter += 1
end
# build filter_complex map. This map specifies which stream of which input
# file should be mapped to which stream of the output. For us this is easy:
# The input files have one audio stream as does the output.
filter_complex_map = (0...files.length).map{ |i| "[#{i}:a:0]" }.join(' ')
filter_complex = filter_complex_map + ' concat=n=' + files.length.to_s + ':v=0:a=1 [a]'
input_files = files.map{ |f| "-i #{f}" }.join(" \\\n\t ")
# Finally: Execute ffmpeg
cmd = "ffmpeg #{input_files} " +
" -filter_complex \"#{filter_complex}\" \\\n\t " +
" -map '[a]' \\\n\t " +
options.join(" \\\n\t ") +
" \\\n\t merged.opus "
puts "$ #{cmd}"
system(cmd)
@LukeLR
Copy link

LukeLR commented Jun 11, 2020

This version does not support filenames with spaces. I added support for such filenames by enclosing some of the generated arguments in (escaped) parentheses :) See the diff below. Maybe merge this with your version? :)

67c67
< 	seconds = `ffprobe -i #{file} -show_entries format=duration -v quiet -of csv="p=0"`.to_i
---
> 	seconds = `ffprobe -i "#{file}" -show_entries format=duration -v quiet -of csv="p=0"`.to_i
78,79c78,79
< 	options.push("-metadata CHAPTER#{num}=#{time}")
< 	options.push("-metadata CHAPTER#{num}NAME=#{name}")
---
> 	options.push("-metadata \"CHAPTER#{num}=#{time}\"")
> 	options.push("-metadata \"CHAPTER#{num}NAME=#{name}\"")
89c89
< input_files = files.map{ |f| "-i #{f}" }.join(" \\\n\t ")
---
> input_files = files.map{ |f| "-i \"#{f}\"" }.join(" \\\n\t ")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment