├── 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 |
--------------------------------------------------------------------------------