Class: Wavesync::Mp4Tmpo
- Inherits:
-
Object
- Object
- Wavesync::Mp4Tmpo
- 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 =
dataatom 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
-
.box(type, payload) ⇒ Object
: (String type, String payload) -> String.
-
.box_header_size(data, offset) ⇒ Object
: (String data, Integer offset) -> Integer.
-
.build_tmpo(bpm) ⇒ Object
: (Integer bpm) -> String.
-
.clamp_uint16(value) ⇒ Object
: (Integer value) -> Integer.
-
.decode_value(bytes, type_flag) ⇒ Object
: (String bytes, Integer type_flag) -> Integer?.
-
.each_box(data, start, finish) ⇒ Object
: (String data, Integer start, Integer finish) { (String, Integer, Integer, Integer) -> void } -> void.
-
.each_descendant(data, start, finish, containers, &block) ⇒ Object
: (String data, Integer start, Integer finish, Array containers) { (String, Integer, Integer, Integer) -> void } -> void.
-
.find_box(data, type, start, finish) ⇒ Object
Returns [offset, size, header_size] of the first direct child of
typewithin [start, finish), or nil. -
.hdlr_mdir_box ⇒ Object
: () -> String.
-
.locate_ilst(data) ⇒ Object
Returns the byte range [start, finish) spanning the ilst children, or nil.
-
.meta_prefix(meta_payload) ⇒ Object
metais a FullBox in MP4 (4-byte version/flags before its children) but a plain box in some QuickTime files. -
.meta_prefix_size(meta_payload) ⇒ Object
: (String meta_payload) -> Integer.
-
.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.
-
.patch_chunk_offsets(moov, delta) ⇒ Object
Adds
deltato every stco/co64 chunk offset, so audio data references stay valid after moov grows ahead of mdat. -
.read_bpm(filepath) ⇒ Object
: (String filepath) -> Integer?.
-
.rebuild_moov(moov, tmpo_box) ⇒ Object
: (String moov, String tmpo_box) -> String.
-
.set_tmpo(data, bpm) ⇒ Object
Rebuilds the moov atom with
tmposet tobpm, fixing up chunk offsets if moov precedes mdat. -
.set_tmpo_box(ilst, tmpo_box) ⇒ Object
: (String ilst, String tmpo_box) -> String.
-
.tmpo_value(data, ilst_start, ilst_finish) ⇒ Object
: (String data, Integer ilst_start, Integer ilst_finish) -> Integer?.
-
.u32(data, offset) ⇒ Object
: (String data, Integer offset) -> Integer.
-
.u64(data, offset) ⇒ Object
: (String data, Integer offset) -> Integer.
-
.update_container(payload, type) ⇒ Object
Replaces the child of
typeinpayloadwith the result of the block (called with the child's payload), or appends a new child if absent. -
.write_bpm(filepath, bpm) ⇒ Object
: (String filepath, Integer | Float | String bpm) -> void.
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_box ⇒ Object
: () -> 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 = find_box(data, 'meta', udta[0] + udta[2], udta[0] + udta[1]) return nil unless meo, mes, meh = children_start = meo + meh + (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.() return ZERO_FLAGS + hdlr_mdir_box if .empty? return '' if () [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.() ().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
214 215 216 |
# File 'lib/wavesync/mp4_tmpo.rb', line 214 def self.() [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 || prefix = () body = [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 |