Class: Wavesync::Mp4Tmpo

Inherits:
Object
  • Object
show all
Defined in:
lib/wavesync/mp4_tmpo.rb

Overview

Reads and writes the iTunes tmpo atom (BPM) in MP4/M4A files.

ffmpeg/ffprobe cannot read or write tmpo, so we parse the atom tree directly — the same hand-rolled approach used for WAV in AcidChunk and CueChunk. tmpo lives at moov → udta → meta → ilst → tmpo → data, where the value is a big-endian integer. This is the form Apple Music writes and the one AVFoundation (and thus audioplayer) reads.

Constant Summary collapse

HEADER_SIZE =

box size (4) + type (4)

8
TYPE_SIZE =
4
FULLBOX_FLAGS_SIZE =

version (1) + flags (3)

4
LOCALE_SIZE =

data atom locale field

4
DATA_TYPE_UTF8 =
1
DATA_TYPE_INTEGER =
21
UINT16 =

iTunes stores BPM as a 16-bit big-endian integer

'n'
UINT32 =
'N'
UINT64 =
'Q>'
ZERO_FLAGS =
[0].pack(UINT32).freeze

Class Method Summary collapse

Class Method Details

.box(type, payload) ⇒ Object

: (String type, String payload) -> String



191
192
193
# File 'lib/wavesync/mp4_tmpo.rb', line 191

def self.box(type, payload)
  [HEADER_SIZE + payload.bytesize].pack(UINT32) + type + payload
end

.box_header_size(data, offset) ⇒ Object

: (String data, Integer offset) -> Integer



266
267
268
# File 'lib/wavesync/mp4_tmpo.rb', line 266

def self.box_header_size(data, offset)
  u32(data, offset) == 1 ? 16 : HEADER_SIZE
end

.build_tmpo(bpm) ⇒ Object

: (Integer bpm) -> String



159
160
161
162
163
# File 'lib/wavesync/mp4_tmpo.rb', line 159

def self.build_tmpo(bpm)
  value = [clamp_uint16(bpm)].pack(UINT16)
  data_payload = [DATA_TYPE_INTEGER].pack(UINT32) + [0].pack(UINT32) + value
  box('tmpo', box('data', data_payload))
end

.clamp_uint16(value) ⇒ Object

: (Integer value) -> Integer



271
272
273
# File 'lib/wavesync/mp4_tmpo.rb', line 271

def self.clamp_uint16(value)
  value.clamp(0, 0xFFFF)
end

.decode_value(bytes, type_flag) ⇒ Object

: (String bytes, Integer type_flag) -> Integer?



72
73
74
75
76
# File 'lib/wavesync/mp4_tmpo.rb', line 72

def self.decode_value(bytes, type_flag)
  return bytes.to_i if type_flag == DATA_TYPE_UTF8

  bytes.bytes.reduce(0) { |acc, byte| (acc << 8) | byte }
end

.each_box(data, start, finish) ⇒ Object

: (String data, Integer start, Integer finish) { (String, Integer, Integer, Integer) -> void } -> void



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/wavesync/mp4_tmpo.rb', line 239

def self.each_box(data, start, finish)
  pos = start
  while pos + HEADER_SIZE <= finish
    size = u32(data, pos)
    hdr = HEADER_SIZE
    if size == 1
      size = u64(data, pos + HEADER_SIZE)
      hdr = 16
    elsif size.zero?
      size = finish - pos
    end
    break if size < hdr || pos + size > finish

    yield data[pos + 4, TYPE_SIZE] || '', pos, size, hdr
    pos += size
  end
end

.each_descendant(data, start, finish, containers, &block) ⇒ Object

: (String data, Integer start, Integer finish, Array containers) { (String, Integer, Integer, Integer) -> void } -> void



258
259
260
261
262
263
# File 'lib/wavesync/mp4_tmpo.rb', line 258

def self.each_descendant(data, start, finish, containers, &block)
  each_box(data, start, finish) do |type, off, size, hdr|
    block.call(type, off, size, hdr)
    each_descendant(data, off + hdr, off + size, containers, &block) if containers.include?(type)
  end
end

.find_box(data, type, start, finish) ⇒ Object

Returns [offset, size, header_size] of the first direct child of type within [start, finish), or nil. : (String data, String type, Integer start, Integer finish) -> [Integer, Integer, Integer]?



227
228
229
230
231
232
233
234
235
236
# File 'lib/wavesync/mp4_tmpo.rb', line 227

def self.find_box(data, type, start, finish)
  result = nil #: [Integer, Integer, Integer]?
  each_box(data, start, finish) do |t, off, size, hdr|
    if t == type
      result = [off, size, hdr]
      break
    end
  end
  result
end

.hdlr_mdir_boxObject

: () -> String



219
220
221
222
# File 'lib/wavesync/mp4_tmpo.rb', line 219

def self.hdlr_mdir_box
  payload = "#{ZERO_FLAGS}#{[0].pack(UINT32)}mdirappl#{[0].pack(UINT32) * 2}\u0000" # empty name, null-terminated
  box('hdlr', payload)
end

.locate_ilst(data) ⇒ Object

Returns the byte range [start, finish) spanning the ilst children, or nil. : (String data) -> [Integer, Integer]?



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/wavesync/mp4_tmpo.rb', line 80

def self.locate_ilst(data)
  moov = find_box(data, 'moov', 0, data.bytesize)
  return nil unless moov

  udta = find_box(data, 'udta', moov[0] + moov[2], moov[0] + moov[1])
  return nil unless udta

  meta = find_box(data, 'meta', udta[0] + udta[2], udta[0] + udta[1])
  return nil unless meta

  meo, mes, meh = meta
  children_start = meo + meh + meta_prefix_size(data[meo + meh, mes - meh] || '')
  ilst = find_box(data, 'ilst', children_start, meo + mes)
  return nil unless ilst

  io, isz, ih = ilst
  [io + ih, io + isz]
end

.meta_prefix(meta_payload) ⇒ Object

meta is a FullBox in MP4 (4-byte version/flags before its children) but a plain box in some QuickTime files. Detect which, and synthesize a valid FullBox header (with an mdir handler) when creating meta from scratch. : (String meta_payload) -> String



199
200
201
202
203
204
# File 'lib/wavesync/mp4_tmpo.rb', line 199

def self.meta_prefix(meta_payload)
  return ZERO_FLAGS + hdlr_mdir_box if meta_payload.empty?
  return '' if non_fullbox_meta?(meta_payload)

  meta_payload[0, FULLBOX_FLAGS_SIZE] || ''
end

.meta_prefix_size(meta_payload) ⇒ Object

: (String meta_payload) -> Integer



207
208
209
# File 'lib/wavesync/mp4_tmpo.rb', line 207

def self.meta_prefix_size(meta_payload)
  meta_prefix(meta_payload).bytesize
end

.non_fullbox_meta?(meta_payload) ⇒ Boolean

A plain (non-FullBox) meta starts with a child box, so a known child type ('hdlr') appears at the type offset of the first box. : (String meta_payload) -> bool

Returns:

  • (Boolean)


214
215
216
# File 'lib/wavesync/mp4_tmpo.rb', line 214

def self.non_fullbox_meta?(meta_payload)
  meta_payload[TYPE_SIZE, TYPE_SIZE] == 'hdlr'
end

.patch_chunk_offsets(moov, delta) ⇒ Object

Adds delta to every stco/co64 chunk offset, so audio data references stay valid after moov grows ahead of mdat. : (String moov, Integer delta) -> String



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/wavesync/mp4_tmpo.rb', line 168

def self.patch_chunk_offsets(moov, delta)
  moov = moov.b
  each_descendant(moov, 0, moov.bytesize, %w[moov trak mdia minf stbl]) do |type, off, _size, hdr|
    next unless %w[stco co64].include?(type)

    count = u32(moov, off + hdr + FULLBOX_FLAGS_SIZE)
    base = off + hdr + FULLBOX_FLAGS_SIZE + 4
    count.times do |i|
      if type == 'stco'
        pos = base + (i * 4)
        moov[pos, 4] = [(u32(moov, pos) + delta) & 0xFFFFFFFF].pack(UINT32)
      else
        pos = base + (i * 8)
        moov[pos, 8] = [u64(moov, pos) + delta].pack(UINT64)
      end
    end
  end
  moov
end

.read_bpm(filepath) ⇒ Object

: (String filepath) -> Integer?



29
30
31
32
33
34
35
36
37
38
# File 'lib/wavesync/mp4_tmpo.rb', line 29

def self.read_bpm(filepath)
  Timing.current.measure(:mp4_chunks) do
    data = File.binread(filepath).b
    ilst = locate_ilst(data)
    next nil unless ilst

    value = tmpo_value(data, ilst[0], ilst[1])
    value&.positive? ? value : nil
  end
end

.rebuild_moov(moov, tmpo_box) ⇒ Object

: (String moov, String tmpo_box) -> String



120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/wavesync/mp4_tmpo.rb', line 120

def self.rebuild_moov(moov, tmpo_box)
  hdr = box_header_size(moov, 0)
  payload = moov[hdr..] || ''

  new_payload = update_container(payload, 'udta') do |udta|
    update_container(udta, 'meta') do |meta|
      prefix = meta_prefix(meta)
      body = meta[prefix.bytesize..] || ''
      prefix + update_container(body, 'ilst') { |ilst| set_tmpo_box(ilst, tmpo_box) }
    end
  end

  box('moov', new_payload)
end

.set_tmpo(data, bpm) ⇒ Object

Rebuilds the moov atom with tmpo set to bpm, fixing up chunk offsets if moov precedes mdat. Returns the same object unchanged when there is no moov atom (not an MP4 we understand). : (String data, Integer bpm) -> String



105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/wavesync/mp4_tmpo.rb', line 105

def self.set_tmpo(data, bpm)
  moov = find_box(data, 'moov', 0, data.bytesize)
  return data unless moov

  mo, ms, = moov
  new_moov = rebuild_moov(data[mo, ms] || '', build_tmpo(bpm))
  delta = new_moov.bytesize - ms

  mdat = find_box(data, 'mdat', 0, data.bytesize)
  new_moov = patch_chunk_offsets(new_moov, delta) if delta != 0 && mdat && mo < mdat[0]

  "#{data[0, mo]}#{new_moov}#{data[(mo + ms)..]}"
end

.set_tmpo_box(ilst, tmpo_box) ⇒ Object

: (String ilst, String tmpo_box) -> String



150
151
152
153
154
155
156
# File 'lib/wavesync/mp4_tmpo.rb', line 150

def self.set_tmpo_box(ilst, tmpo_box)
  found = find_box(ilst, 'tmpo', 0, ilst.bytesize)
  return ilst + tmpo_box unless found

  off, size, = found
  "#{ilst[0, off]}#{tmpo_box}#{ilst[(off + size)..]}"
end

.tmpo_value(data, ilst_start, ilst_finish) ⇒ Object

: (String data, Integer ilst_start, Integer ilst_finish) -> Integer?



56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/wavesync/mp4_tmpo.rb', line 56

def self.tmpo_value(data, ilst_start, ilst_finish)
  tmpo = find_box(data, 'tmpo', ilst_start, ilst_finish)
  return nil unless tmpo

  t_off, t_size, t_hdr = tmpo
  box = find_box(data, 'data', t_off + t_hdr, t_off + t_size)
  return nil unless box

  d_off, d_size, d_hdr = box
  type_flag = u32(data, d_off + d_hdr) & 0xFFFFFF
  value_start = d_off + d_hdr + FULLBOX_FLAGS_SIZE + LOCALE_SIZE
  bytes = data[value_start, (d_off + d_size) - value_start] || ''
  decode_value(bytes, type_flag)
end

.u32(data, offset) ⇒ Object

: (String data, Integer offset) -> Integer



276
277
278
279
# File 'lib/wavesync/mp4_tmpo.rb', line 276

def self.u32(data, offset)
  value = (data[offset, 4] || '').unpack1(UINT32)
  value.is_a?(Integer) ? value : 0
end

.u64(data, offset) ⇒ Object

: (String data, Integer offset) -> Integer



282
283
284
285
# File 'lib/wavesync/mp4_tmpo.rb', line 282

def self.u64(data, offset)
  value = (data[offset, 8] || '').unpack1(UINT64)
  value.is_a?(Integer) ? value : 0
end

.update_container(payload, type) ⇒ Object

Replaces the child of type in payload with the result of the block (called with the child's payload), or appends a new child if absent. : (String payload, String type) { (String) -> String } -> String



138
139
140
141
142
143
144
145
146
147
# File 'lib/wavesync/mp4_tmpo.rb', line 138

def self.update_container(payload, type)
  found = find_box(payload, type, 0, payload.bytesize)
  if found
    off, size, hdr = found
    new_child = yield(payload[off + hdr, size - hdr] || '')
    "#{payload[0, off]}#{box(type, new_child)}#{payload[(off + size)..]}"
  else
    payload + box(type, yield(''))
  end
end

.write_bpm(filepath, bpm) ⇒ Object

: (String filepath, Integer | Float | String bpm) -> void



41
42
43
44
45
46
47
48
49
50
51
# File 'lib/wavesync/mp4_tmpo.rb', line 41

def self.write_bpm(filepath, bpm)
  Timing.current.measure(:mp4_chunks) do
    data = File.binread(filepath).b
    updated = set_tmpo(data, bpm.to_i)
    next if updated.equal?(data)

    temp_path = "#{filepath}.tmp"
    File.binwrite(temp_path, updated)
    FileUtils.mv(temp_path, filepath)
  end
end