├── LICENSE ├── README.md ├── audioqc ├── audioqc_methods.rb ├── deprecated ├── audioqc ├── audioqc.config ├── dropout-example.png └── makespectrums ├── media_conch_policy.xml └── settings.csv /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, University of Washington and Andrew Weaver 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # audioqc 2 | 3 | ## About 4 | This tool is intended to assist with batch/collection level quality control of archival WAV files digitized from analog sources. It can target directories and single audio files, and will generate audio quality control reports in CSV to the desktop or user specified location. It scans for peak/average audio levels, files with 'hot' portions exceeding a user set limit, audio phase, file integrity (from embedded MD5 checksums), bext metadata conformance and mediaconch policy conformance. It also can generate images of the audio spectrum and waveform of each input file. 5 | 6 | Development note: This tool was rewritten in 2025 to simplify usage, code and dependencies. For the legacy code, see [here](https://github.com/amiaopensource/audioqc/tree/new-code-base/deprecated) or the [final release](https://github.com/amiaopensource/audioqc/releases/tag/2025-05-23) containing the previous code. 7 | 8 | 9 | ## Setup 10 | 11 | Requires Ruby, CLI versions of FFmpeg/FFprobe, Mediainfo and Mediaconch. 12 | Configurations, such as dependency paths can be set in the associated CSV file. 13 | 14 | ### Mac: 15 | * All dependencies and audioqc scripts can be installed via the [Homebrew package manager](https://brew.sh/) 16 | * Once Homebrew is installed, run the commands `brew tap amiaopensource/amiaos` followed by `brew install audioqc` to complete the install process. 17 | 18 | ### Windows: 19 | * [Ruby](https://rubyinstaller.org/) will need to be installed if it isn't present already. 20 | * All dependencies will have to be added to the 'Path' (or have their locations noted in the configuration file) and should be the command line version (CLI) of their respective tools 21 | * MediaConch, MediaInfo and BWF MetaEdit can be downloaded from the [MediaArea](https://mediaarea.net/) website 22 | * ffprobe can be downloaded as part of the [FFmpeg package](https://ffmpeg.org/download.html#build-windows) 23 | 24 | 25 | ### Linux: 26 | * Most dependencies should be installable through the standard package manager. 27 | * For the most up to date versions of MediaArea dependencies it is recommended to activate the [MediaArea](https://mediaarea.net/en/Repos) repository 28 | 29 | ## Usage: 30 | Usage: `audioqc [options] TARGET(s)` Target can be either individual files, or a directory, or a combination of the two. This will result in a CSV output to your desktop. 31 | Available options are: 32 | 33 | -o, --output=val Optional output path for CSV results file 34 | -c, --conch=val Path to optional mediaconch policy XML file 35 | -j, --jpg Create visualizations of input files (waveform and spectrum) 36 | 37 | Configuration of this tool can be done via the associated `settings.csv` file. Configurable options include settings for what the tools considers 'out of range' for volume and phase, as well as paths for output, mediaconch policies and dependencies. 38 | 39 | Note: The scan can take a while to run on large batches of files - this is expected! 40 | 41 | ## Maintainers 42 | Andrew Weaver (@privatezero) 43 | Susie Cummings (@susiecummings) 44 | -------------------------------------------------------------------------------- /audioqc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'json' 4 | require 'csv' 5 | require 'optparse' 6 | load 'audioqc_methods.rb' 7 | load_options('settings.csv') 8 | ARGV << '-h' if ARGV.empty? 9 | 10 | #Set up options 11 | parser = OptionParser.new 12 | parser.banner = "Usage: ruby audioqc2.rb [options] [inputs]" 13 | parser.on('-o', '--output=val', "Optional output path for CSV file", String) { |val| $output_path_custom = val } 14 | parser.on('-c', '--conch=val', "Path to optional mediaconch policy XML file", String) { |val| $conch_policy = val } 15 | parser.on('-j', '--jpg', "Create visualizations of input files") { $visualize_yes = true } 16 | parser.parse! 17 | 18 | #Get targets from file or directory input(s) 19 | targets = ARGV 20 | file_inputs = [] 21 | qc_files = [] 22 | timestamp = Time.now.strftime('%Y-%m-%d_%H-%M-%S') 23 | 24 | targets.each do |target| 25 | target = File.expand_path(target) 26 | if File.directory?(target) 27 | targets = Dir["#{target}/**/*.{WAV,wav}"] 28 | targets.each {|file| file_inputs << File.expand_path(file)} 29 | elsif File.extname(target).downcase == '.wav' 30 | file_inputs << target 31 | end 32 | if file_inputs.empty? 33 | puts "No valid target files found! Please check inputs" 34 | exit 35 | end 36 | end 37 | 38 | if File.exist?($output_path_custom.to_s) 39 | output_csv_path = $output_path_custom 40 | else 41 | output_csv_path = ENV['HOME'] + "/Desktop/" 42 | end 43 | output_csv_name = "audioqc-out_#{timestamp}.csv" 44 | output_csv = "#{output_csv_path}/#{output_csv_name}" 45 | output_jpg_dir = "#{output_csv_path}/#{timestamp}_jpgs" 46 | 47 | #QC each input and output to CSV 48 | file_inputs.each {|file| qc_files << QcTarget.new(file)} 49 | 50 | qc_files.each do |file| 51 | begin 52 | file.media_info 53 | file.media_conch 54 | file.calculatehash 55 | file.probe 56 | file.phase 57 | file.generate_warnings 58 | rescue 59 | file.error_warning 60 | end 61 | file.write_csv_line(output_csv) 62 | file.make_jpg(output_jpg_dir) if $visualize_yes 63 | end 64 | 65 | 66 | #Parallel version 67 | # require 'parallel' 68 | # #Calculate hash of audio stream 69 | # hashes = Parallel.map(qc_files) {|file| file.calculatehash} 70 | 71 | # hashes.each_with_index do |hash, index| 72 | # qc_files[index].store_hash(hash) 73 | # end 74 | 75 | # #Calculate FFprobe information of input files 76 | # probe_data = Parallel.map(qc_files) {|file| file.probe} 77 | 78 | # probe_data.each_with_index do |probe, index| 79 | # qc_files[index].store_probe(probe) 80 | # end 81 | #Calculate average phase 82 | # phase_data = Parallel.map(qc_files, in_processes: 1) {|file| file.phase} 83 | # phase_data.each_with_index do |phase, index| 84 | # qc_files[index].store_phase(phase) 85 | # end -------------------------------------------------------------------------------- /audioqc_methods.rb: -------------------------------------------------------------------------------- 1 | def load_options(option_file) 2 | options = CSV.parse(File.read(option_file)) 3 | $high_volume = options[1][0].to_f 4 | $stereo_phase_thresh = options[1][1].to_f 5 | $dual_mono_phase_thresh = options[1][2].to_f 6 | $conch_policy = options[1][3] 7 | $output_path_custom = options[1][4] 8 | $ffmpeg_path = options[1][5] 9 | $ffprobe_path = options[1][6] 10 | $mediaconch_path = options[1][7] 11 | $ffmpeg_path = 'ffmpeg' if ! File.exist?($ffmpeg_path.to_s) 12 | $ffprobe_path = 'ffprobe' if ! File.exist?($ffprobe_path.to_s) 13 | $mediaconch_path = 'mediaconch' if ! File.exist?($mediaconch_path.to_s) 14 | end 15 | 16 | class QcTarget 17 | def initialize(value) 18 | @input_path = value 19 | @warnings = [] 20 | @hash = '' 21 | end 22 | 23 | def calculatehash 24 | @md5 = `#{$ffmpeg_path} -nostdin -i "#{@input_path}" -c copy -f md5 -`.chomp.reverse.chomp('=5DM').reverse.upcase 25 | end 26 | 27 | def probe 28 | channel_one_vol = [] 29 | channel_two_vol = [] 30 | overall_volume = [] 31 | @high_volume_count = 0 32 | ffprobe_command = "#{$ffprobe_path} -print_format json -threads auto -show_entries frame_tags=lavfi.astats.Overall.Number_of_samples,lavfi.astats.Overall.Peak_level,lavfi.astats.Overall.Max_difference,lavfi.astats.1.Peak_level,lavfi.astats.2.Peak_level,lavfi.astats.1.Peak_level,lavfi.astats.Overall.Mean_difference,lavfi.astats.Overall.Peak_level,lavfi.r128.I -f lavfi -i \"amovie='#{@input_path}'" + ',astats=reset=1:metadata=1,ebur128=metadata=1"' 33 | ffprobe_command.gsub!(':','\:') 34 | ffprobe_out = JSON.parse(`#{ffprobe_command}`) 35 | ffprobe_out['frames'].each do |frame| 36 | if frame['tags']['lavfi.astats.1.Peak_level'] == '-inf' || frame['tags']['lavfi.astats.2.Peak_level'] == '-inf' 37 | next 38 | else 39 | channel_one_vol << frame['tags']['lavfi.astats.1.Peak_level'].to_f.round(2) 40 | channel_two_vol << frame['tags']['lavfi.astats.2.Peak_level'].to_f.round(2) unless frame['tags']['lavfi.astats.2.Peak_level'].nil? 41 | overall_volume << frame['tags']['lavfi.astats.Overall.Peak_level'].to_f.round(2) 42 | end 43 | end 44 | @integratedLoudness = ffprobe_out['frames'][ffprobe_out.length - 3]['tags']['lavfi.r128.I'] 45 | @channel_one_max = channel_one_vol.max 46 | @channel_two_max = channel_two_vol.max 47 | @overall_volume_max = overall_volume.max 48 | overall_volume.each {|volume| @high_volume_count += 1 if volume > $high_volume} 49 | output = [@channel_one_max, @channel_two_max, @overall_volume_max, @integratedLoudness] 50 | end 51 | 52 | def phase 53 | phase_values = [] 54 | phase_command = `#{$ffmpeg_path} -i "#{@input_path}" -af aformat=dblp,channelsplit,axcorrelate=size=1024:algo=fast -f wav - | #{$ffprobe_path} -print_format json -threads auto -show_entries frame_tags=lavfi.astats.1.DC_offset -f lavfi -i "amovie='pipe\\:0',astats=reset=1:metadata=1"` 55 | phase_info = JSON.parse(phase_command) 56 | phase_info['frames'].each {|frame| phase_values << frame['tags']['lavfi.astats.1.DC_offset'].to_f} 57 | @average_phase = (phase_values.sum/phase_values.count).round(2) 58 | end 59 | 60 | def media_conch 61 | @media_conch_out = CSV.parse(`#{$mediaconch_path} --Policy=#{$conch_policy} --Format=csv "#{@input_path}"`) 62 | @conch_failures = [] 63 | if @media_conch_out[1][1] != 'pass' 64 | @conch_result = 'fail' 65 | @warnings << 'media conch fail' 66 | @media_conch_out[1].each_with_index do |value, index| 67 | if value =='fail' 68 | @conch_failures << @media_conch_out[0][index] 69 | end 70 | end 71 | else 72 | @conch_result = 'pass' 73 | end 74 | end 75 | 76 | def media_info 77 | @media_info_out = JSON.parse(`mediainfo --Output=JSON "#{@input_path}"`) 78 | @channel_count = @media_info_out['media']['track'][1]['Channels'] 79 | @duration_normalized = Time.at(@media_info_out['media']['track'][0]['Duration'].to_f).utc.strftime('%H:%M:%S') 80 | #check for BEXT coding history metadata 81 | if (@media_info_out['media']['track'][0]['extra'] != nil) 82 | if @media_info_out['media']['track'][0]['extra']['bext_Present'] == 'Yes' && @media_info_out['media']['track'][0]['Encoded_Library_Settings'] 83 | @coding_history = @media_info_out['media']['track'][0]['Encoded_Library_Settings'] 84 | @stereo_count = @media_info_out['media']['track'][0]['Encoded_Library_Settings'].scan(/stereo/i).count 85 | @mono_count = @media_info_out['media']['track'][0]['Encoded_Library_Settings'].scan(/mono/i).count 86 | @dual_count = @media_info_out['media']['track'][0]['Encoded_Library_Settings'].gsub("dual-sided","").scan(/dual/i).count 87 | @signal_chain_count = @media_info_out['media']['track'][0]['Encoded_Library_Settings'].scan(/A=/).count 88 | end 89 | else 90 | @warnings << 'No BEXT' 91 | end 92 | if @media_info_out['media']['track'][1]['extra'] 93 | @stored_md5 = @media_info_out['media']['track'][1]['extra']['MD5'].chomp 94 | else 95 | @stored_md5 = nil 96 | end 97 | end 98 | 99 | 100 | # only used in parallel version 101 | # def store_hash(hash) 102 | # @md5 = hash 103 | # end 104 | 105 | #only used in parallel version 106 | # def store_probe(ffprobe_out) 107 | # @channel_one_max = ffprobe_out[0] 108 | # @channel_two_max = ffprobe_out[1] 109 | # @integratedLoudness = ffprobe_out[2] 110 | # end 111 | 112 | #only used in parallel version 113 | # def store_phase(average_phase) 114 | # @average_phase = average_phase 115 | # end 116 | 117 | def generate_warnings 118 | #MD5 Warnings 119 | if @stored_md5.nil? 120 | @warnings << 'No Stored MD5' 121 | @md5_alert = 'No MD5' 122 | elsif @stored_md5 != @md5 123 | @warnings << 'Failed MD5 Verification' 124 | @md5_alert = "Failed: #{@md5}" 125 | else 126 | @md5_alert = 'Pass' 127 | end 128 | 129 | #Average Phase Warnings 130 | if ! @dual_count.nil? && ! @stereo_count.nil? 131 | if @dual_count > 0 132 | phase_limit = $dual_mono_phase_thresh 133 | elsif @stereo_count > 1 134 | phase_limit = $stereo_phase_thresh 135 | else 136 | phase_limit = $stereo_phase_thresh 137 | end 138 | else 139 | phase_limit = $stereo_phase_thresh 140 | end 141 | if @average_phase < phase_limit 142 | @warnings << 'Phase Warning' 143 | end 144 | 145 | #Volume Warnings 146 | if @channel_one_max > $high_volume || @overall_volume_max > $high_volume 147 | @warnings << "High Volume" 148 | elsif ! @channel_two_max.nil? && @channel_two_max > $high_volume 149 | @warnings << "High Volume" 150 | end 151 | 152 | # Check Coding History for accuracy vs. channels 153 | if ! @dual_count.nil? && ! @stereo_count.nil? 154 | if @channel_count == "1" 155 | unless (@mono_count - @dual_count) == @signal_chain_count 156 | @warnings << "BEXT Coding History channels don't match file" 157 | end 158 | elsif @channel_count == "2" 159 | unless @stereo_count + @dual_count == @signal_chain_count 160 | @warnings << "BEXT Coding History channels don't match file" 161 | end 162 | end 163 | end 164 | @status = 'pass' 165 | end 166 | 167 | def make_jpg(output_jpg_dir) 168 | Dir.mkdir(output_jpg_dir) unless File.exist?(output_jpg_dir) 169 | output_path = output_jpg_dir + '/' + File.basename(@input_path,File.extname(@input_path)) + '.jpg' 170 | `ffmpeg -i #{@input_path} -f lavfi -i color=c=#c0c0c0:s=938x240 -filter_complex " \ 171 | [0:a]asplit=3[a][b][c],[a]showwavespic=s=938x240:split_channels=1:colors=#3232c8:filter=peak[pk], \ 172 | [b]showwavespic=s=938x240:split_channels=1:colors=#6464dc[rms], \ 173 | [c]showspectrumpic=s=640x240[spectrum], \ 174 | [pk][rms]overlay=format=auto[nobg], \ 175 | [1:v][nobg]overlay=format=auto[bg], \ 176 | [bg][spectrum]vstack=inputs=2,drawtext=fontsize=20:fontcolor=black:text="#{File.basename(@input_path)}"[out0]" \ 177 | -map [out0] -frames:v 1 -update true #{output_path}` 178 | end 179 | 180 | def error_warning 181 | @status = 'fail' 182 | end 183 | 184 | def csv_line 185 | return "@warnings,@input_path,@channel_one_max,@channel_two_max,@average_phase,@md5" 186 | end 187 | 188 | def write_csv_line(output_csv) 189 | if ! File.exist?(output_csv) 190 | header = ['Path', 'Warnings', 'Channels', 'Duration', 'Volume max', 'Channel 1 max', 'Channel 2 max', 'Number of High Volume Frames', 'Average Phase', 'Integrated Loudness', 'MD5 check', 'Mediaconch Status', 'Mediaconch Failures', 'Coding History'] 191 | CSV.open(output_csv, 'a') do |csv| 192 | csv << header 193 | end 194 | end 195 | if @status == 'fail' 196 | line = [@input_path, 'Failed to Scan'] 197 | elsif @status == 'pass' 198 | line = [@input_path,@warnings.flatten.join(', '),@channel_count, @duration_normalized, @overall_volume_max, @channel_one_max,@channel_two_max,@high_volume_count,@average_phase,@integratedLoudness,@md5_alert, @conch_result, @conch_failures.flatten.join(', '),@coding_history] 199 | end 200 | CSV.open(output_csv, 'a') do |csv| 201 | csv << line 202 | end 203 | end 204 | end -------------------------------------------------------------------------------- /deprecated/audioqc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # frozen_string_literal: true 4 | 5 | require 'json' 6 | require 'tempfile' 7 | require 'csv' 8 | require 'optparse' 9 | require 'yaml' 10 | 11 | Ruby_Version = RUBY_VERSION.to_f 12 | 13 | # Load config file 14 | config_file = "#{__dir__}/audioqc.config" 15 | Configurations = YAML.load(File.open(config_file).read) 16 | 17 | # Check system 18 | if Gem::Platform.local.os == 'mingw32' 19 | System = 'windows' 20 | elsif Gem::Platform.local.os == 'linux' 21 | System = 'linux' 22 | elsif 23 | Gem::Platform.local.os == 'darwin' 24 | System = 'mac' 25 | else 26 | puts "Operating system has not been correctly detected. Linux will be assumed - errors may occur!" 27 | System = 'linux' 28 | end 29 | 30 | 31 | dependencies = ['bwfmetaedit', 'ffprobe', 'mediaconch'] 32 | missing_dependencies = [] 33 | unless System == 'windows' 34 | dependencies.each {|test| missing_dependencies << test unless system("which #{test} > /dev/null")} 35 | if missing_dependencies.length > 0 36 | missing_dependencies.each {|missing| puts "Please install missing dependency: #{missing}"} 37 | exit 38 | end 39 | end 40 | 41 | # This controls option flags 42 | # -p option allows you to select a custom mediaconch policy file - otherwise script uses default 43 | # -e allows you to select a target file extenstion for the script to use. 44 | # If no extenstion is specified it will target the default 'wav' extension. (Not case sensitive) 45 | options = [] 46 | ARGV.options do |opts| 47 | opts.on('-a', '--all') { options += ['meta', 'bext', 'signal', 'md5'] } 48 | opts.on('-b', '--bext-scan') { options << 'bext' } 49 | opts.on('-c', '--checksum') { options << 'md5' } 50 | opts.on('-d', '--dropout-scan') { options << 'dropouts' } 51 | opts.on('-e', '--Extension=val', String) { |val| TARGET_EXTENSION = val.downcase } 52 | opts.on('-m', '--meta-scan') { options << 'meta' } 53 | opts.on('-o', '--options') { options << 'edit-options'} 54 | opts.on('-p', '--Policy=val', String) { |val| POLICY_FILE = val } 55 | opts.on('-s', '--signal-scan') { options << 'signal' } 56 | opts.parse! 57 | end 58 | 59 | if options.include?('edit-options') 60 | if System == 'linux' 61 | system('xdg-open', config_file) 62 | elsif System == 'mingw32' 63 | system('start','notepad', config_file) 64 | else 65 | system('open', config_file) 66 | end 67 | exit 68 | elsif options.count == 0 && ARGV.count == 0 69 | puts 'For list of available options please run: audioqc -h' 70 | exit 71 | elsif options.count == 0 72 | options = Configurations['default_options'] 73 | end 74 | 75 | 76 | # set up arrays and variables 77 | TARGET_EXTENSION = Configurations['default_extension'] unless defined? TARGET_EXTENSION 78 | 79 | # set up output CSV path 80 | timestamp = Time.now.strftime('%Y-%m-%d_%H-%M-%S') 81 | if Configurations['csv_output_path'].empty? 82 | output_csv = ENV['HOME'] + "/Desktop/audioqc-out_#{timestamp}.csv" 83 | else 84 | output_csv = Configurations['csv_output_path'] + "/audioqc-out_#{timestamp}.csv" 85 | end 86 | if ! Dir.exist?(File.dirname(output_csv)) 87 | puts "Output directory not found. Please configure a valid output directory" 88 | exit 1 89 | end 90 | 91 | # Start embedded WAV Mediaconch policy section 92 | # Policy derived from MediaConch Public Policies. Original Maintainer Peter B. License: CC-BY-4.0+ 93 | mc_policy = <<~EOS 94 | 95 | 96 | This is the common norm for WAVE audiofiles. 97 | Any WAVs not matching this policy should be inspected and possibly normalized to conform to this. 98 | 99 | Signed 100 | Float 101 | 102 | 103 | This policy defines audio-resolution values that are proper for WAV. 104 | 105 | This was not implemented as rule in order to avoid irregular sampling rates. 106 | 107 | 108 | 109 | 96000 110 | 111 | 112 | 113 | 114 | 115 | 116 | 24 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 4000000000 126 | 127 | Wave 128 | PCM 129 | Little 130 | 131 | EOS 132 | # End embedded WAV Mediaconch policy section 133 | 134 | if TARGET_EXTENSION == 'wav' 135 | unless defined? POLICY_FILE 136 | POLICY_FILE = Tempfile.new('mediaConch') 137 | POLICY_FILE.write(mc_policy) 138 | POLICY_FILE.rewind 139 | end 140 | end 141 | 142 | class QcTarget 143 | def initialize(value) 144 | @input_path = value 145 | @warnings = [] 146 | end 147 | 148 | def check_dropouts 149 | @sample_ratios = [] 150 | @possible_drops = [] 151 | @ffprobe_out['frames'].each do |frames| 152 | @sample_ratios << frames['tags']['lavfi.astats.Overall.Max_difference'].to_f / frames['tags']['lavfi.astats.Overall.Mean_difference'].to_f 153 | end 154 | @sample_ratios.each_with_index do |ratio, i| 155 | unless i + 1 == @sample_ratios.length 156 | diff_prior = (ratio - @sample_ratios[i - 1]).abs 157 | diff_post = (ratio - @sample_ratios[i + 1]).abs 158 | if diff_prior > 15 && diff_post > 15 159 | # I think there is something wonky with how ffmpeg splits to frames vs samples - this math for finding time needs to be looked at 160 | @possible_drops << normalize_time(i * @ffprobe_out['frames'][0]['tags']['lavfi.astats.Overall.Number_of_samples'].to_f / @mediainfo_out['media']['track'][1]['SamplingRate'].to_f) 161 | end 162 | end 163 | end 164 | @warnings << "Possible Dropouts Detected" if @possible_drops.length > 0 165 | end 166 | 167 | def check_md5 168 | puts "Verifying embedded MD5 for #{@input_path}" 169 | md5_output = `bwfmetaedit --MD5-Verify -v "#{@input_path}" 2>&1`.chomp.split("\n") 170 | if md5_output.any? {|line| line.include?('MD5, no existing MD5 chunk')} 171 | @warnings << 'No MD5' 172 | @md5_status = 'No MD5' 173 | elsif md5_output.any? {|line| line.include?('MD5, failed verification')} 174 | @warnings << 'Failed MD5 Verification' 175 | @md5_status = 'Failed' 176 | elsif ! md5_output.any? {|line| line.include?('MD5, verified')} 177 | @warnings << 'MD5 check unable to be performed' 178 | @md5_status = 'MD5 check unable to be performed' 179 | else 180 | @md5_status = 'Pass' 181 | end 182 | end 183 | 184 | def check_metaedit 185 | scan_output = `bwfmetaedit "#{@input_path}" 2>&1`.chomp.chomp 186 | @wave_conformance = scan_output.split(':').last.strip if scan_output.include?('invalid') 187 | if @wave_conformance.nil? 188 | @wave_conformance = ' ' 189 | else 190 | @warnings << "Invalid Wave Detected" unless @wave_conformance.nil? 191 | end 192 | end 193 | 194 | def get_ffprobe_phase_normalized(volume_command) 195 | ffprobe_command = 'ffmpeg -i ' + @input_path + volume_command + ' -f wav - | ffprobe -print_format json -threads auto -show_entries frame_tags=lavfi.aphasemeter.phase -f lavfi -i "amovie=' + "'" + 'pipe\\:0' + "'" + ',astats=reset=1:metadata=1,aphasemeter=video=0,ebur128=metadata=1"' 196 | @ffprobe_phase = JSON.parse(`#{ffprobe_command}`) 197 | end 198 | 199 | def get_ffprobe 200 | if @channel_count == "2" 201 | channel_one_vol = [] 202 | channel_two_vol = [] 203 | ffprobe_command = "ffprobe -print_format json -threads auto -show_entries frame_tags=lavfi.astats.Overall.Number_of_samples,lavfi.astats.Overall.Peak_level,lavfi.astats.Overall.Max_difference,lavfi.astats.1.Peak_level,lavfi.astats.2.Peak_level,lavfi.astats.1.Peak_level,lavfi.astats.Overall.Mean_difference,lavfi.astats.Overall.Peak_level,lavfi.r128.I -f lavfi -i \"amovie='#{@input_path}'" + ',astats=reset=1:metadata=1,ebur128=metadata=1"' 204 | ffprobe_command.gsub!(':','\:') 205 | @ffprobe_out = JSON.parse(`#{ffprobe_command}`) 206 | @ffprobe_out['frames'].each do |frame| 207 | if frame['tags']['lavfi.astats.1.Peak_level'] == '-inf' || frame['tags']['lavfi.astats.2.Peak_level'] == '-inf' 208 | next 209 | else 210 | channel_one_vol << frame['tags']['lavfi.astats.1.Peak_level'].to_f 211 | channel_two_vol << frame['tags']['lavfi.astats.2.Peak_level'].to_f 212 | end 213 | end 214 | @channel_one_max = channel_one_vol.max 215 | @channel_two_max = channel_two_vol.max 216 | channel_dif = (channel_one_vol.max - channel_two_vol.max).abs.to_s 217 | if channel_two_vol.max < channel_one_vol.max 218 | @volume_command = ' -filter_complex "[0:a]channelsplit[a][b],[b]volume=volume=' + channel_dif + 'dB:precision=fixed[c],[a][c]amerge[out1]" -map [out1] ' 219 | else 220 | @volume_command = ' -filter_complex "[0:a]channelsplit[a][b],[a]volume=volume=' + channel_dif + 'dB:precision=fixed[c],[c][b]amerge[out1]" -map [out1] ' 221 | end 222 | get_ffprobe_phase_normalized(@volume_command) 223 | else 224 | ffprobe_command = "ffprobe -print_format json -threads auto -show_entries frame_tags=lavfi.astats.Overall.Number_of_samples,lavfi.astats.Overall.Peak_level,lavfi.astats.Overall.Max_difference,lavfi.astats.1.Peak_level,lavfi.astats.Overall.Mean_difference,lavfi.astats.Overall.Peak_level,lavfi.aphasemeter.phase,lavfi.r128.I -f lavfi -i \"amovie='#{@input_path}'" + ',astats=reset=1:metadata=1,aphasemeter=video=0,ebur128=metadata=1"' 225 | ffprobe_command.gsub!(':','\:') 226 | @ffprobe_out = JSON.parse(`#{ffprobe_command}`) 227 | @ffprobe_phase = @ffprobe_out 228 | end 229 | @total_frame_count = @ffprobe_out['frames'].size 230 | end 231 | 232 | def check_phase 233 | out_of_phase_frames = [] 234 | phase_frames = [] 235 | unless @mediainfo_out['media']['track'][0]['extra'].nil? || TARGET_EXTENSION != 'wav' 236 | if @mediainfo_out['media']['track'][0]['extra']['bext_Present'] == 'Yes' && @mediainfo_out['media']['track'][0]['Encoded_Library_Settings'] 237 | @stereo_count = @mediainfo_out['media']['track'][0]['Encoded_Library_Settings'].scan(/stereo/i).count 238 | @dual_count = @mediainfo_out['media']['track'][0]['Encoded_Library_Settings'].scan(/dual/i).count 239 | end 240 | end 241 | if ! @dual_count.nil? && ! @stereo_count.nil? 242 | if @dual_count > 0 243 | phase_limit = Configurations['dualmono_audio_phase_limit'] 244 | elsif @stereo_count > 1 245 | phase_limit = Configurations['stereo_audio_phase_limit'] 246 | else 247 | phase_limit = Configurations['generic_audio_phase_limit'] 248 | end 249 | else 250 | phase_limit = Configurations['generic_audio_phase_limit'] 251 | end 252 | @ffprobe_phase['frames'].each do |frames| 253 | audiophase = frames['tags']['lavfi.aphasemeter.phase'].to_f 254 | phase_frames << audiophase 255 | out_of_phase_frames << audiophase if audiophase < phase_limit 256 | end 257 | @phasey_frame_count = out_of_phase_frames.size 258 | if Ruby_Version > 2.7 259 | @average_phase = (phase_frames.sum(0.0) / phase_frames.size).round(2) 260 | else 261 | @average_phase = (phase_frames.reduce(:+) / phase_frames.size).round(2) 262 | end 263 | @warnings << 'PHASE WARNING' if @phasey_frame_count > 50 264 | end 265 | 266 | def find_peaks_loudness_n_phase 267 | high_db_frames = [] 268 | @levels = [] 269 | @ffprobe_out['frames'].each do |frames| 270 | peaklevel = frames['tags']['lavfi.astats.Overall.Peak_level'] 271 | if peaklevel != '-inf' 272 | high_db_frames << peaklevel.to_f if peaklevel.to_f > Configurations['high_level_warning'] 273 | @levels << peaklevel.to_f 274 | end 275 | end 276 | 277 | @max_level = @levels.max.round(2) 278 | @high_level_count = high_db_frames.size 279 | if Ruby_Version > 2.7 280 | @average_levels = (@levels.sum(0.0) / @levels.size).round(2) 281 | else 282 | @average_levels = (@levels.reduce(:+) / @levels.size).round(2) 283 | end 284 | @integratedLoudness = @ffprobe_out['frames'][@ffprobe_out.length - 3]['tags']['lavfi.r128.I'] 285 | @warnings << 'LEVEL WARNING' if @high_level_count > 0 286 | end 287 | 288 | def get_mediainfo 289 | @mediainfo_out = JSON.parse(`mediainfo --Output=JSON "#{@input_path}"`) 290 | @duration_normalized = Time.at(@mediainfo_out['media']['track'][0]['Duration'].to_f).utc.strftime('%H:%M:%S') 291 | @channel_count = @mediainfo_out['media']['track'][1]['Channels'] 292 | end 293 | 294 | # Function to scan file for mediaconch compliance 295 | def media_conch_scan(policy) 296 | if File.file?(policy) 297 | @qc_results = [] 298 | policy_path = File.path(policy) 299 | command = 'mediaconch --Policy=' + '"' + policy_path + '" ' + '"' + @input_path + '"' 300 | media_conch_out = `#{command}`.gsub(@input_path, "") 301 | media_conch_out.strip! 302 | media_conch_out.split('/n').each {|qcline| @qc_results << qcline} 303 | @qc_results = @qc_results.to_s.gsub('\n -- ', '; ') 304 | if File.exist?(policy) 305 | if @qc_results.include?('pass!') 306 | @qc_results = 'PASS' 307 | else 308 | @warnings << 'MEDIACONCH FAIL' 309 | end 310 | end 311 | else 312 | @qc_results = policy 313 | end 314 | end 315 | 316 | def normalize_time(time_source) 317 | Time.at(time_source).utc.strftime('%H:%M:%S:%m') 318 | end 319 | 320 | def output_csv_line(options) 321 | if options.include?('error') 322 | line = [@input_path, 'FAILED TO PARSE'] 323 | else 324 | line = [@input_path, @warnings.flatten.join(', '), @duration_normalized, @channel_count] 325 | end 326 | if options.include?('dropouts') 327 | line << @possible_drops 328 | end 329 | if options.include?('signal') 330 | line += [@average_levels, @max_level,@high_level_count] 331 | if @channel_count == "2" 332 | line+= [@channel_one_max,@channel_two_max] 333 | else 334 | line += [' ', ' '] 335 | end 336 | line +=[@average_phase, @phasey_frame_count, @integratedLoudness] 337 | end 338 | if options.include?('meta') 339 | line += [@wave_conformance] unless TARGET_EXTENSION != 'wav' 340 | line += [@qc_results] 341 | end 342 | 343 | if options.include?('md5') 344 | line += [@md5_status] 345 | end 346 | if options.include?('bext') 347 | line += [@encoding_history] 348 | end 349 | return line 350 | end 351 | 352 | def output_warnings 353 | @warnings 354 | end 355 | 356 | def qc_encoding_history 357 | if TARGET_EXTENSION == 'wav' 358 | @enc_hist_error = [] 359 | unless @mediainfo_out['media']['track'][0]['extra'].nil? 360 | if @mediainfo_out['media']['track'][0]['extra']['bext_Present'] == 'Yes' && @mediainfo_out['media']['track'][0]['Encoded_Library_Settings'] 361 | @encoding_history = @mediainfo_out['media']['track'][0]['Encoded_Library_Settings'] 362 | signal_chain_count = @mediainfo_out['media']['track'][0]['Encoded_Library_Settings'].scan(/A=/).count 363 | if @channel_count == "1" 364 | unless @mediainfo_out['media']['track'][0]['Encoded_Library_Settings'].scan(/mono/i).count == signal_chain_count 365 | @enc_hist_error << "BEXT Coding History channels don't match file" 366 | end 367 | end 368 | 369 | if @channel_count == "2" 370 | @stereo_count = @mediainfo_out['media']['track'][0]['Encoded_Library_Settings'].scan(/stereo/i).count 371 | @dual_count = @mediainfo_out['media']['track'][0]['Encoded_Library_Settings'].gsub("dual-sided","").scan(/dual/i).count 372 | unless @stereo_count + @dual_count == signal_chain_count 373 | @enc_hist_error << "BEXT Coding History channels don't match file" 374 | end 375 | end 376 | end 377 | else 378 | @enc_hist_error << "Encoding history not present" 379 | end 380 | @warnings << @enc_hist_error if @enc_hist_error.size > 0 381 | end 382 | end 383 | end 384 | 385 | def write_csv_line(output_csv,line) 386 | CSV.open(output_csv, 'a') do |csv| 387 | csv << line 388 | end 389 | end 390 | 391 | # Make list of inputs 392 | file_inputs = [] 393 | write_to_csv = [] 394 | ARGV.each do |input| 395 | input_normalized = input.gsub('\\','/') 396 | # If input is directory, recursively add all files with target extension to target list 397 | if File.directory?(input_normalized) 398 | targets = Dir["#{input_normalized}/**/*.{#{TARGET_EXTENSION.upcase},#{TARGET_EXTENSION.downcase}}"] 399 | targets.each do |file| 400 | file_inputs << file 401 | end 402 | # If input is file, add it to target list (if extension matches target extension) 403 | elsif File.extname(input_normalized).downcase == '.' + TARGET_EXTENSION.downcase && File.exist?(input) 404 | file_inputs << input 405 | else 406 | puts "Input: #{input} not found!" 407 | end 408 | end 409 | 410 | if file_inputs.empty? 411 | puts 'No targets found!' 412 | exit 413 | else 414 | file_inputs.sort! 415 | end 416 | 417 | 418 | # Begin CSV 419 | CSV.open(output_csv, 'wb') do |csv| 420 | headers = ['Filename', 'Warnings', 'Duration', 'Channels'] 421 | if options.include?('dropouts') 422 | headers << 'Possible Drops' 423 | end 424 | 425 | if options.include?('signal') 426 | headers += ['Average Level', 'Peak Level', 'Number of Frames w/ High Levels', 'Channel 1 Max', 'Channel 2 Max', 'Average Phase', 'Number of Phase Warnings', 'Integrated Loudness'] 427 | end 428 | 429 | if options.include?('meta') 430 | headers << 'Wave Conformance Errors' unless TARGET_EXTENSION != 'wav' 431 | headers << 'MediaConch Policy Compliance' 432 | end 433 | 434 | if options.include?('md5') 435 | headers << 'MD5 check' 436 | end 437 | 438 | if options.include?('bext') 439 | headers << 'Coding History' 440 | end 441 | csv << headers 442 | end 443 | 444 | 445 | # Scan files 446 | file_inputs.each do |fileinput| 447 | begin 448 | puts "Scanning: #{fileinput}" 449 | targetPath = File.expand_path(fileinput) 450 | target = QcTarget.new(targetPath) 451 | target.get_mediainfo 452 | if options.include?('meta') 453 | if defined? POLICY_FILE 454 | target.media_conch_scan(POLICY_FILE) 455 | else 456 | target.media_conch_scan('Valid Policy File Not Found') 457 | end 458 | target.check_metaedit unless TARGET_EXTENSION != 'wav' 459 | end 460 | if options.include?('bext') 461 | target.qc_encoding_history 462 | end 463 | if options.include?('md5') 464 | target.check_md5 465 | end 466 | if options.include?('signal') || options.include?('dropouts') 467 | target.get_ffprobe 468 | if options.include?('signal') 469 | target.find_peaks_loudness_n_phase 470 | target.check_phase 471 | end 472 | if options.include?('dropouts') 473 | target.check_dropouts 474 | end 475 | end 476 | write_csv_line(output_csv,target.output_csv_line(options)) 477 | rescue 478 | puts "Error scanning: #{targetPath}" 479 | write_csv_line(output_csv,target.output_csv_line('error')) 480 | end 481 | end 482 | -------------------------------------------------------------------------------- /deprecated/audioqc.config: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # Set QC values here 4 | 5 | stereo_audio_phase_limit: -0.25 6 | dualmono_audio_phase_limit: 0.95 7 | generic_audio_phase_limit: -0.25 8 | high_level_warning: -2.0 9 | default_extension: 'wav' 10 | csv_output_path: '' 11 | default_options: [signal, meta, bext, md5] 12 | -------------------------------------------------------------------------------- /deprecated/dropout-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amiaopensource/audioqc/2bcf4f9656a23a36f3855b4b7406586b869c6d4d/deprecated/dropout-example.png -------------------------------------------------------------------------------- /deprecated/makespectrums: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # frozen_string_literal: true 3 | 4 | require 'fileutils' 5 | 6 | TARGET_EXTENSION = 'wav' 7 | file_inputs = [] 8 | spectrum_files = [] 9 | 10 | ARGV.each do |input| 11 | input_normalized = input.gsub('\\','/') 12 | # If input is directory, recursively add all files with target extension to target list 13 | if File.directory?(input_normalized) 14 | targets = Dir["#{input_normalized}/**/*.{#{TARGET_EXTENSION.upcase},#{TARGET_EXTENSION.downcase}}"] 15 | targets.each do |file| 16 | file_inputs << file 17 | end 18 | # If input is file, add it to target list (if extension matches target extension) 19 | elsif File.extname(input_normalized).downcase == '.' + TARGET_EXTENSION.downcase && File.exist?(input) 20 | file_inputs << input 21 | else 22 | puts "Input: #{input} not found!" 23 | end 24 | end 25 | 26 | if file_inputs.empty? 27 | puts 'No targets found!' 28 | exit 29 | end 30 | 31 | file_inputs.sort! 32 | file_inputs.each do |target| 33 | spectrum_out = File.dirname(target) + "/" + File.basename(target,".*") + '.jpg' 34 | spectrum_files << spectrum_out 35 | filter = "highpass=f=8000,showspectrumpic=fscale=lin,drawtext=fontsize=56:fontcolor=white:text=#{File.basename(target)}" 36 | system('ffmpeg', '-i',target,'-lavfi',filter,'-y',spectrum_out) 37 | # if File.exist?(spectrum_out) 38 | # pdf_page = spectrum_out + '.pdf' 39 | # system('convert',spectrum_out,pdf_page) 40 | # end 41 | end 42 | 43 | puts 44 | 45 | 46 | timestamp = Time.now.strftime('%Y-%m-%d_%H-%M-%S') 47 | output_location = ENV['HOME'] + "/Desktop/audioqc-spectrum-report_#{timestamp}.pdf" 48 | spectrum_compile_command = 'convert ' 49 | spectrum_files.each {|spectrum_page| spectrum_compile_command += "'#{spectrum_page}' "} 50 | spectrum_compile_command += output_location 51 | #`convert #{spectrum_compile_list} 'test.pdf'` 52 | #system('convert', spectrum_compile_list, 'test.pdf') 53 | # if `#{spectrum_compile_command}` 54 | # spectrum_files.each {|input| FileUtils.rm(input)} 55 | # end 56 | 57 | 58 | -------------------------------------------------------------------------------- /media_conch_policy.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | This is the common norm for WAVE audiofiles. 4 | Any WAVs not matching this policy should be inspected and possibly normalized to conform to this. 5 | 6 | Signed 7 | Float 8 | 9 | 10 | This policy defines audio-resolution values that are proper for WAV. 11 | 12 | This was not implemented as rule in order to avoid irregular sampling rates. 13 | 14 | 15 | 16 | 96000 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 4000000000 33 | 34 | Wave 35 | PCM 36 | Little 37 | -------------------------------------------------------------------------------- /settings.csv: -------------------------------------------------------------------------------- 1 | High Volume threshold, Phase threshold (Stereo), Phase threshold (Dual-mono), Policy Path, Output Path, Optional FFmpeg Path,Optional FFprobe path, Optional Mediaconch path 2 | -1,0.2,0.9,media_conch_policy.xml,,,, 3 | --------------------------------------------------------------------------------