about summary refs log tree commit homepage
path: root/lib/dtas/source/av_ff_common.rb
blob: bcb8a0a85e8fb1f426e6d959268601b972b9ad9d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
require_relative '../../dtas'
require_relative '../source'
require_relative '../replaygain'
require_relative '../xs'
require_relative 'file'

# Common code for libav (avconv/avprobe) and ffmpeg (and ffprobe)
# TODO: newer versions of both *probes support JSON, which will be easier to
# parse.  However, the packaged libav version in Debian 7.0 does not
# support JSON, so we have an ugly parser...
module DTAS::Source::AvFfCommon # :nodoc:
  include DTAS::Source::File
  include DTAS::XS
  AStream = Struct.new(:duration, :channels, :rate)
  AV_FF_TRYORDER = 1

  attr_reader :precision # always 32
  attr_reader :format

  def try(infile, offset = nil)
    rv = source_file_dup(infile, offset)
    rv.av_ff_ok? or return
    rv
  end

  def av_ff_ok?
    @duration = nil
    @format = DTAS::Format.new
    @format.bits = 32 # always, since we still use the "sox" format
    @comments = {}
    @astreams = []
    cmd = %W(#@av_ff_probe -show_streams -show_format #@infile)
    err = ""
    s = qx(@env, cmd, err_str: err, no_raise: true)
    return false if Process::Status === s
    return false if err =~ /Unable to find a suitable output format for/
    s.scan(%r{^\[STREAM\]\n(.*?)\n\[/STREAM\]\n}mn) do |_|
      stream = $1
      if stream =~ /^codec_type=audio$/
        as = AStream.new
        index = nil
        stream =~ /^index=(\d+)\s*$/nm and index = $1.to_i
        stream =~ /^duration=([\d\.]+)\s*$/nm and as.duration = $1.to_f
        stream =~ /^channels=(\d)\s*$/nm and as.channels = $1.to_i
        stream =~ /^sample_rate=([\d\.]+)\s*$/nm and as.rate = $1.to_i
        index or raise "BUG: no audio index from #{xs(cmd)}"

        # some streams have zero channels
        @astreams[index] = as if as.channels > 0 && as.rate > 0
      end
    end
    s.scan(%r{^\[FORMAT\]\n(.*?)\n\[/FORMAT\]\n}m) do |_|
      f = $1
      f =~ /^duration=([\d\.]+)\s*$/nm and @duration = $1.to_f
      # TODO: multi-line/multi-value/repeated tags
      f.gsub!(/^TAG:([^=]+)=(.*)$/ni) { |_| @comments[$1.upcase] = $2 }
    end
    ! @astreams.empty?
  end

  def sspos(offset)
    offset =~ /\A(\d+)s\z/ or return "-ss #{offset}"
    samples = $1.to_f
    sprintf("-ss %0.9g", samples / @format.rate)
  end

  def select_astream(as)
    @format.channels = as.channels
    @format.rate = as.rate

    # favor the duration of the stream we're playing instead of
    # duration we got from [FORMAT].  However, some streams may not have
    # a duration and only have it in [FORMAT]
    @duration = as.duration if as.duration
  end

  def amap_fallback
    @astreams.each_with_index do |as, index|
      as or next
      select_astream(as)
      warn "no suitable audio stream in #@infile, trying stream=#{index}"
      return "-map 0:#{i}"
    end
    raise "BUG: no audio stream in #@infile"
  end

  def spawn(player_format, rg_state, opts)
    raise "BUG: #{self.inspect}#spawn called twice" if @to_io
    amap = nil

    # try to find an audio stream which matches our channel count
    # we need to set @format for sspos() down below
    @astreams.each_with_index do |as, i|
      if as && as.channels == player_format.channels
        select_astream(as)
        amap = "-map 0:#{i}"
      end
    end

    # fall back to the first audio stream
    # we must call select_astream before sspos
    amap ||= amap_fallback

    e = @env.merge!(player_format.to_env)

    # make sure these are visible to the source command...
    e["INFILE"] = @infile
    e["AMAP"] = amap
    e["SSPOS"] = @offset ? sspos(@offset) : nil
    e["RGFX"] = rg_state.effect(self) || nil
    e.merge!(@rg.to_env) if @rg

    @pid = dtas_spawn(e, command_string, opts)
  end

  # This is the number of samples according to the samples in the source
  # file itself, not the decoded output
  def samples
    @samples ||= (@duration * @format.rate).round
  rescue
    0
  end

  def to_hsh
    sd = source_defaults
    to_hash.delete_if { |k,v| v == sd[k] }
  end
end