av_ff_common: use mcache
[dtas.git] / lib / dtas / source / av_ff_common.rb
blobc600c48be100877c42e0b4ad7fda85d2b47ef418
1 # Copyright (C) all contributors <dtas-all@nongnu.org>
2 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
3 # frozen_string_literal: true
4 require_relative '../../dtas'
5 require_relative '../source'
6 require_relative '../replaygain'
7 require_relative '../xs'
8 require_relative 'file'
10 # Common code for ffmpeg/ffprobe and the abandoned libav (avconv/avprobe).
11 # TODO: newer versions of both *probes support JSON, which will be easier
12 # to parse.  libav is abandoned, nowadays, and Debian only packages
13 # ffmpeg+ffprobe nowadays.
14 module DTAS::Source::AvFfCommon # :nodoc:
15   include DTAS::Source::File
16   include DTAS::XS
17   AStream = Struct.new(:duration, :channels, :rate)
18   AV_FF_TRYORDER = 1
20   attr_reader :precision # always 32
21   attr_reader :format
22   attr_reader :duration
24   CACHE_KEYS = [ :@duration, :@probe_harder, :@comments, :@astreams,
25                  :@format ].freeze
27   def mcache_lookup(infile)
28     (@mcache ||= DTAS::Mcache.new).lookup(infile) do |input, dst|
29       tmp = source_file_dup(infile, nil, nil)
30       tmp.av_ff_ok? or return nil
31       CACHE_KEYS.each { |k| dst[k] = tmp.instance_variable_get(k) }
32       dst
33     end
34   end
36   def try(infile, offset = nil, trim = nil)
37     ent = mcache_lookup(infile) or return
38     ret = source_file_dup(infile, offset, trim)
39     CACHE_KEYS.each { |k| ret.instance_variable_set(k, ent[k]) }
40     ret
41   end
43   def __parse_astream(cmd, stream)
44     stream =~ /^codec_type=audio$/ or return
45     as = AStream.new
46     index = nil
47     stream =~ /^index=(\d+)\s*$/nm and index = $1.to_i
48     stream =~ /^duration=([\d\.]+)\s*$/nm and as.duration = $1.to_f
49     stream =~ /^channels=(\d)\s*$/nm and as.channels = $1.to_i
50     stream =~ /^sample_rate=([\d\.]+)\s*$/nm and as.rate = $1.to_i
51     index or raise "BUG: no audio index from #{xs(cmd)}"
52     yield(index, as)
53   end
55   def probe_ok?(status, err_str)
56     return false if Process::Status === status
57     return false if err_str =~ /Unable to find a suitable output format for/
58     true
59   end
61   def av_ff_ok?
62     @duration = nil
63     @format = DTAS::Format.new
64     @format.bits = 32 # always, since we still use the "sox" format
65     @comments = {}
66     @astreams = []
68     # needed for VOB and other formats which scatter metadata all over the
69     # place and
70     @probe_harder = nil
71     incomplete = []
72     prev_cmd = []
74     begin # loop
75       cmd = %W(#@av_ff_probe)
77       # using the max known duration as a analyzeduration seems to work
78       # for the few VOBs I've tested, but seeking is still broken.
79       max_duration = 0
80       incomplete.each do |as|
81         as && as.duration or next
82         max_duration = as.duration if as.duration > max_duration
83       end
84       if max_duration > 0
85         usec = max_duration.round * 1000000
86         usec = "2G" if usec >= 0x7fffffff # limited to INT_MAX :<
87         @probe_harder = %W(-analyzeduration #{usec} -probesize 2G)
88         cmd.concat(@probe_harder)
89       end
90       cmd.concat(%W(-show_streams -show_format #@infile))
91       break if cmd == prev_cmd
93       err = "".b
94       begin
95         s = qx(@env, cmd, err_str: err, no_raise: true)
96       rescue Errno::ENOENT # avprobe/ffprobe not installed
97         return false
98       end
99       return false unless probe_ok?(s, err)
101       # old avprobe
102       [ %r{^\[STREAM\]\n(.*?)\n\[/STREAM\]\n}mn,
103         %r{^\[streams\.stream\.\d+\]\n(.*?)\n\n}mn ].each do |re|
104         s.scan(re) do |_|
105           __parse_astream(cmd, $1) do |index, as|
106             # incomplete streams may have zero channels
107             if as.channels > 0 && as.rate > 0
108               @astreams[index] = as
109               incomplete[index] = nil
110             else
111               incomplete[index] = as
112             end
113           end
114         end
115       end
117       prev_cmd = cmd
118     end while incomplete.compact[0]
120     enc = Encoding.default_external # typically Encoding::UTF_8
121     # old avprobe
122     s.scan(%r{^\[FORMAT\]\n(.*?)\n\[/FORMAT\]\n}m) do |_|
123       f = $1.dup
124       f =~ /^duration=([\d\.]+)\s*$/nm and @duration = $1.to_f
125       # TODO: multi-line/multi-value/repeated tags
126       f.gsub!(/^TAG:([^=]+)=(.*)$/ni) { |_|
127         @comments[-DTAS.try_enc($1.upcase, enc)] = $2
128       }
129     end
131     # new avprobe
132     s.scan(%r{^\[format\.tags\]\n(.*?)\n\n}m) do |_|
133       f = $1.dup
134       f.gsub!(/^([^=]+)=(.*)$/ni) { |_|
135         @comments[-DTAS.try_enc($1.upcase, enc)] = $2
136       }
137     end
138     s.scan(%r{^\[format\]\n(.*?)\n\n}m) do |_|
139       f = $1.dup
140       f =~ /^duration=([\d\.]+)\s*$/nm and @duration = $1.to_f
141     end
142     comments.each do |k,v|
143       v.chomp!
144       comments[k] = -DTAS.try_enc(v, enc)
145     end
147     # ffprobe always uses "track", favor FLAC convention "TRACKNUMBER":
148     if @comments['TRACK'] && !@comments['TRACKNUMBER']
149       @comments['TRACKNUMBER'] = @comments.delete('TRACK')
150     end
152     ! @astreams.compact.empty?
153   end
155   def sspos
156     return unless @offset || @trim
157     off = offset_samples / @format.rate.to_f
158     sprintf('-ss %0.9g', off)
159   end
161   def av_ff_trimfx # for sox
162     return unless @trim
163     tbeg, tlen = @trim # Floats
164     tend = tbeg + tlen
165     off = offset_samples / @format.rate.to_f
166     tlen = tend - off
167     tlen = 0 if tlen < 0
168     sprintf('trim 0 %0.9g', tlen)
169   end
171   def select_astream(as)
172     @format.channels = as.channels
173     @format.rate = as.rate
175     # favor the duration of the stream we're playing instead of
176     # duration we got from [FORMAT].  However, some streams may not have
177     # a duration and only have it in [FORMAT]
178     @duration = as.duration if as.duration
179   end
181   def amap_fallback
182     @astreams.each_with_index do |as, index|
183       as or next
184       select_astream(as)
185       warn "no suitable audio stream in #@infile, trying stream=#{index}"
186       return "-map 0:#{index}"
187     end
188     raise "BUG: no audio stream in #@infile"
189   end
191   def src_spawn(player_format, rg_state, opts)
192     raise "BUG: #{self.inspect}#src_spawn called twice" if @to_io
193     amap = nil
195     # try to find an audio stream which matches our channel count
196     # we need to set @format for sspos() down below
197     @astreams.each_with_index do |as, i|
198       if as && as.channels == player_format.channels
199         select_astream(as)
200         amap = "-map 0:#{i}"
201       end
202     end
204     # fall back to the first audio stream
205     # we must call select_astream before sspos
206     amap ||= amap_fallback
208     e = @env.merge!(player_format.to_env)
210     e["PROBE"] = @probe_harder ? @probe_harder.join(' ') : nil
211     # make sure these are visible to the source command...
212     e["INFILE"] = @infile
213     e["AMAP"] = amap
214     e["SSPOS"] = sspos
215     e["RGFX"] = rg_state.effect(self) || nil
216     e["TRIMFX"] = av_ff_trimfx
217     e.merge!(@rg.to_env) if @rg
219     @pid = dtas_spawn(e, command_string, opts)
220   end
222   # This is the number of samples according to the samples in the source
223   # file itself, not the decoded output
224   def samples
225     @samples ||= (@duration * @format.rate).round
226   rescue
227     0
228   end
230   def to_hsh
231     sd = source_defaults
232     to_hash.delete_if { |k,v| v == sd[k] }
233   end