wavesync 1.0.0.alpha4 → 1.0.0.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
+ require_relative 'timing'
5
+
4
6
  module Wavesync
5
7
  class CueChunk
6
8
  RIFF_HEADER_SIZE = 12
@@ -10,122 +12,157 @@ module Wavesync
10
12
  LIST_CHUNK_ID = 'LIST'
11
13
  ADTL_LIST_TYPE = 'adtl'
12
14
  LABL_CHUNK_ID = 'labl'
15
+ NOTE_CHUNK_ID = 'note'
13
16
  DATA_CHUNK_ID = 'data'
14
17
  CUE_HEADER_SIZE = 4
15
18
  BYTES_PER_CUE_POINT = 24
16
19
 
20
+ LOOP_START_NOTE = 'start'
21
+ LOOP_END_NOTE = 'end'
22
+
17
23
  UINT32_LE = 'V'
18
24
 
19
- #: (String filepath) -> Array[{identifier: Integer, sample_offset: Integer, label: String?}]
25
+ #: (String filepath) -> Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}]
20
26
  def self.read(filepath)
21
- cue_points = [] #: Array[{identifier: Integer, sample_offset: Integer, label: String?}]
22
- labels = {} #: Hash[Integer, String]
27
+ Timing.current.measure(:wav_chunks) do
28
+ cue_points = [] #: Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}]
29
+ labels = {} #: Hash[Integer, String]
30
+ notes = {} #: Hash[Integer, String]
23
31
 
24
- File.open(filepath, 'rb') do |file|
25
- file.seek(RIFF_HEADER_SIZE)
32
+ File.open(filepath, 'rb') do |file|
33
+ file.seek(RIFF_HEADER_SIZE)
26
34
 
27
- until file.eof?
28
- chunk_id = file.read(CHUNK_ID_SIZE)
29
- break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
35
+ until file.eof?
36
+ chunk_id = file.read(CHUNK_ID_SIZE)
37
+ break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
30
38
 
31
- chunk_size_bytes = file.read(CHUNK_SIZE_FIELD_SIZE)
32
- break if chunk_size_bytes.nil?
39
+ chunk_size_bytes = file.read(CHUNK_SIZE_FIELD_SIZE)
40
+ break if chunk_size_bytes.nil?
33
41
 
34
- chunk_size = chunk_size_bytes.unpack1(UINT32_LE).to_i
35
- chunk_data_start = file.tell
42
+ chunk_size = chunk_size_bytes.unpack1(UINT32_LE).to_i
43
+ chunk_data_start = file.tell
36
44
 
37
- if chunk_id == CUE_CHUNK_ID
38
- num_cues = file.read(4)&.unpack1(UINT32_LE).to_i
39
- num_cues.times do
40
- identifier = file.read(4)&.unpack1(UINT32_LE)&.to_i
41
- file.read(16) # skip position, fcc_chunk, chunk_start, block_start
42
- sample_offset = file.read(4)&.unpack1(UINT32_LE)&.to_i
43
- cue_points << { identifier: identifier, sample_offset: sample_offset, label: nil } if identifier && sample_offset
45
+ if chunk_id == CUE_CHUNK_ID
46
+ num_cues = file.read(4)&.unpack1(UINT32_LE).to_i
47
+ num_cues.times do
48
+ identifier = file.read(4)&.unpack1(UINT32_LE)&.to_i
49
+ file.read(16) # skip position, fcc_chunk, chunk_start, block_start
50
+ sample_offset = file.read(4)&.unpack1(UINT32_LE)&.to_i
51
+ cue_points << { identifier: identifier, sample_offset: sample_offset, label: nil, note: nil } if identifier && sample_offset
52
+ end
53
+ elsif chunk_id == LIST_CHUNK_ID && chunk_size >= 4
54
+ list_type = file.read(4)
55
+ read_adtl_entries(file, chunk_data_start + chunk_size, labels, notes) if list_type == ADTL_LIST_TYPE
44
56
  end
45
- elsif chunk_id == LIST_CHUNK_ID && chunk_size >= 4
46
- list_type = file.read(4)
47
- read_adtl_labels(file, chunk_data_start + chunk_size, labels) if list_type == ADTL_LIST_TYPE
57
+
58
+ chunk_padding = chunk_size.odd? ? 1 : 0
59
+ file.seek(chunk_data_start + chunk_size + chunk_padding)
48
60
  end
61
+ end
49
62
 
50
- chunk_padding = chunk_size.odd? ? 1 : 0
51
- file.seek(chunk_data_start + chunk_size + chunk_padding)
63
+ cue_points.map do |cue_point|
64
+ {
65
+ identifier: cue_point[:identifier],
66
+ sample_offset: cue_point[:sample_offset],
67
+ label: labels[cue_point[:identifier]],
68
+ note: notes[cue_point[:identifier]]
69
+ }
52
70
  end
53
71
  end
54
-
55
- cue_points.map { |cue_point| { identifier: cue_point[:identifier], sample_offset: cue_point[:sample_offset], label: labels[cue_point[:identifier]] } }
56
72
  end
57
73
 
58
- #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points_a, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points_b) -> bool
74
+ #: (Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}] cue_points_a, Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}] cue_points_b) -> bool
59
75
  def self.same?(cue_points_a, cue_points_b)
60
76
  to_comparable(cue_points_a) == to_comparable(cue_points_b)
61
77
  end
62
78
 
63
- #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> Array[{sample_offset: Integer, label: String?}]
79
+ #: (Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}] cue_points) -> Array[{sample_offset: Integer, label: String?, note: String?}]
64
80
  def self.to_comparable(cue_points)
65
- mapped = cue_points.map { |cp| { sample_offset: cp[:sample_offset], label: cp[:label] } } #: Array[{sample_offset: Integer, label: String?}]
81
+ mapped = cue_points.map { |cp| { sample_offset: cp[:sample_offset], label: cp[:label], note: cp[:note] } } #: Array[{sample_offset: Integer, label: String?, note: String?}]
66
82
  mapped.sort_by { |cp| cp[:sample_offset] }
67
83
  end
68
84
 
69
- #: (String filepath, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
70
- def self.append_to_file(filepath, cue_points)
71
- return if cue_points.empty?
72
-
73
- File.open(filepath, 'ab') do |file|
74
- write_cue_chunk(file, cue_points)
75
- labeled_cue_points = cue_points.select { |cue_point| cue_point[:label] }
76
- write_adtl_chunk(file, labeled_cue_points) if labeled_cue_points.any?
77
- end
85
+ #: ({identifier: Integer, sample_offset: Integer, label: String?, note: String?} cue_point) -> bool
86
+ def self.loop_marker?(cue_point)
87
+ note = cue_point[:note]
88
+ return false if note.nil?
78
89
 
79
- update_riff_size(filepath)
90
+ [LOOP_START_NOTE, LOOP_END_NOTE].include?(note)
80
91
  end
81
92
 
82
- #: (String source_filepath, String output_filepath, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
83
- def self.write(source_filepath, output_filepath, cue_points)
84
- File.open(source_filepath, 'rb') do |input|
85
- File.open(output_filepath, 'wb') do |output|
86
- output.write(input.read(RIFF_HEADER_SIZE))
93
+ #: (Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}] cue_points) -> Array[{start_sample: Integer, end_sample: Integer}]
94
+ def self.loops(cue_points)
95
+ starts = cue_points.select { |cue_point| cue_point[:note] == LOOP_START_NOTE }.sort_by { |cue_point| cue_point[:sample_offset] }
96
+ ends = cue_points.select { |cue_point| cue_point[:note] == LOOP_END_NOTE }.sort_by { |cue_point| cue_point[:sample_offset] }
97
+ starts.zip(ends).filter_map do |loop_start, loop_end|
98
+ next nil unless loop_start && loop_end && loop_end[:sample_offset] > loop_start[:sample_offset]
87
99
 
88
- until input.eof?
89
- chunk_id = input.read(CHUNK_ID_SIZE)
90
- break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
100
+ { start_sample: loop_start[:sample_offset], end_sample: loop_end[:sample_offset] }
101
+ end
102
+ end
91
103
 
92
- chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE)
93
- break if chunk_size_bytes.nil?
104
+ #: (String filepath, Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}] cue_points) -> void
105
+ def self.append_to_file(filepath, cue_points)
106
+ return if cue_points.empty?
94
107
 
95
- chunk_size = chunk_size_bytes.unpack1(UINT32_LE).to_i
96
- chunk_padding = chunk_size.odd? ? 1 : 0
108
+ Timing.current.measure(:wav_chunks) do
109
+ File.open(filepath, 'ab') do |file|
110
+ write_cue_chunk(file, cue_points)
111
+ write_adtl_chunk(file, cue_points) if cue_points.any? { |cue_point| cue_point[:label] || cue_point[:note] }
112
+ end
97
113
 
98
- if chunk_id == CUE_CHUNK_ID
99
- input.read(chunk_size + chunk_padding)
100
- elsif chunk_id == LIST_CHUNK_ID && chunk_size >= 4
101
- list_type = input.read(4)
102
- if list_type == ADTL_LIST_TYPE
103
- input.read(chunk_size - 4 + chunk_padding)
114
+ update_riff_size(filepath)
115
+ end
116
+ end
117
+
118
+ #: (String source_filepath, String output_filepath, Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}] cue_points) -> void
119
+ def self.write(source_filepath, output_filepath, cue_points)
120
+ Timing.current.measure(:wav_chunks) do
121
+ File.open(source_filepath, 'rb') do |input|
122
+ File.open(output_filepath, 'wb') do |output|
123
+ output.write(input.read(RIFF_HEADER_SIZE))
124
+
125
+ until input.eof?
126
+ chunk_id = input.read(CHUNK_ID_SIZE)
127
+ break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
128
+
129
+ chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE)
130
+ break if chunk_size_bytes.nil?
131
+
132
+ chunk_size = chunk_size_bytes.unpack1(UINT32_LE).to_i
133
+ chunk_padding = chunk_size.odd? ? 1 : 0
134
+
135
+ if chunk_id == CUE_CHUNK_ID
136
+ input.read(chunk_size + chunk_padding)
137
+ elsif chunk_id == LIST_CHUNK_ID && chunk_size >= 4
138
+ list_type = input.read(4)
139
+ if list_type == ADTL_LIST_TYPE
140
+ input.read(chunk_size - 4 + chunk_padding)
141
+ else
142
+ output.write(chunk_id)
143
+ output.write(chunk_size_bytes)
144
+ output.write(list_type)
145
+ output.write(input.read(chunk_size - 4 + chunk_padding))
146
+ end
104
147
  else
105
148
  output.write(chunk_id)
106
149
  output.write(chunk_size_bytes)
107
- output.write(list_type)
108
- output.write(input.read(chunk_size - 4 + chunk_padding))
150
+ output.write(input.read(chunk_size + chunk_padding))
109
151
  end
110
- else
111
- output.write(chunk_id)
112
- output.write(chunk_size_bytes)
113
- output.write(input.read(chunk_size + chunk_padding))
114
152
  end
115
- end
116
153
 
117
- unless cue_points.empty?
118
- write_cue_chunk(output, cue_points)
119
- labeled_cue_points = cue_points.select { |cue_point| cue_point[:label] }
120
- write_adtl_chunk(output, labeled_cue_points) if labeled_cue_points.any?
154
+ unless cue_points.empty?
155
+ write_cue_chunk(output, cue_points)
156
+ write_adtl_chunk(output, cue_points) if cue_points.any? { |cue_point| cue_point[:label] || cue_point[:note] }
157
+ end
121
158
  end
122
159
  end
123
- end
124
160
 
125
- update_riff_size(output_filepath)
161
+ update_riff_size(output_filepath)
162
+ end
126
163
  end
127
164
 
128
- #: (untyped output, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
165
+ #: (untyped output, Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}] cue_points) -> void
129
166
  def self.write_cue_chunk(output, cue_points)
130
167
  chunk_size = CUE_HEADER_SIZE + (cue_points.size * BYTES_PER_CUE_POINT)
131
168
  output.write(CUE_CHUNK_ID)
@@ -142,22 +179,21 @@ module Wavesync
142
179
  end
143
180
  end
144
181
 
145
- #: (untyped output, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
182
+ #: (untyped output, Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}] cue_points) -> void
146
183
  def self.write_adtl_chunk(output, cue_points)
147
- labl_entries = cue_points.map do |cue_point|
148
- text = "#{cue_point[:label]}\x00"
149
- data_size = 4 + text.length
150
- pad = data_size.odd? ? 1 : 0
151
- { identifier: cue_point[:identifier], text: text, data_size: data_size, pad: pad }
152
- end #: Array[{identifier: Integer, text: String, data_size: Integer, pad: Integer}]
153
-
154
- adtl_data_size = 4 + labl_entries.sum { |entry| 8 + entry[:data_size] + entry[:pad] }
184
+ entries = [] #: Array[{sub_id: String, identifier: Integer, text: String, data_size: Integer, pad: Integer}]
185
+ cue_points.each do |cue_point|
186
+ entries << build_adtl_entry(LABL_CHUNK_ID, cue_point[:identifier], cue_point[:label]) if cue_point[:label]
187
+ entries << build_adtl_entry(NOTE_CHUNK_ID, cue_point[:identifier], cue_point[:note]) if cue_point[:note]
188
+ end
189
+
190
+ adtl_data_size = 4 + entries.sum { |entry| 8 + entry[:data_size] + entry[:pad] }
155
191
  output.write(LIST_CHUNK_ID)
156
192
  output.write([adtl_data_size].pack(UINT32_LE))
157
193
  output.write(ADTL_LIST_TYPE)
158
194
 
159
- labl_entries.each do |entry|
160
- output.write(LABL_CHUNK_ID)
195
+ entries.each do |entry|
196
+ output.write(entry[:sub_id])
161
197
  output.write([entry[:data_size]].pack(UINT32_LE))
162
198
  output.write([entry[:identifier]].pack(UINT32_LE))
163
199
  output.write(entry[:text])
@@ -165,8 +201,16 @@ module Wavesync
165
201
  end
166
202
  end
167
203
 
168
- #: (untyped file, Integer list_end, Hash[Integer, String] labels) -> void
169
- def self.read_adtl_labels(file, list_end, labels)
204
+ #: (String sub_id, Integer identifier, String text_content) -> {sub_id: String, identifier: Integer, text: String, data_size: Integer, pad: Integer}
205
+ def self.build_adtl_entry(sub_id, identifier, text_content)
206
+ text = "#{text_content}\x00"
207
+ data_size = 4 + text.length
208
+ pad = data_size.odd? ? 1 : 0
209
+ { sub_id: sub_id, identifier: identifier, text: text, data_size: data_size, pad: pad }
210
+ end
211
+
212
+ #: (untyped file, Integer list_end, Hash[Integer, String] labels, Hash[Integer, String] notes) -> void
213
+ def self.read_adtl_entries(file, list_end, labels, notes)
170
214
  while file.tell < list_end - 8
171
215
  sub_id = file.read(CHUNK_ID_SIZE)
172
216
  break if sub_id.nil? || sub_id.length < CHUNK_ID_SIZE
@@ -177,11 +221,12 @@ module Wavesync
177
221
  sub_size = sub_size_bytes.unpack1(UINT32_LE).to_i
178
222
  sub_data_start = file.tell
179
223
 
180
- if sub_id == LABL_CHUNK_ID && sub_size >= 4
224
+ if [LABL_CHUNK_ID, NOTE_CHUNK_ID].include?(sub_id) && sub_size >= 4
181
225
  identifier = file.read(4)&.unpack1(UINT32_LE)&.to_i
182
226
  text_size = sub_size - 4
183
227
  text = text_size.positive? ? (file.read(text_size) || '') : ''
184
- labels[identifier] = text.delete("\x00") if identifier
228
+ target = sub_id == LABL_CHUNK_ID ? labels : notes
229
+ target[identifier] = text.delete("\x00") if identifier
185
230
  end
186
231
 
187
232
  sub_padding = sub_size.odd? ? 1 : 0
@@ -12,10 +12,11 @@ module Wavesync
12
12
  attr_reader :bar_multiple #: Integer?
13
13
  attr_reader :unsupported_characters #: Array[String]
14
14
  attr_reader :transliterate_metadata #: bool
15
+ attr_reader :transliterate_paths #: bool
15
16
  attr_reader :uppercase_paths #: bool
16
17
 
17
- #: (name: String, sample_rates: Array[Integer], file_types: Array[String], ?bit_depths: Array[Integer], ?bpm_source: Symbol?, ?bar_multiple: Integer?, ?unsupported_characters: Array[String], ?transliterate_metadata: bool, ?uppercase_paths: bool) -> void
18
- def initialize(name:, sample_rates:, file_types:, bit_depths: [], bpm_source: nil, bar_multiple: nil, unsupported_characters: [], transliterate_metadata: false, uppercase_paths: false)
18
+ #: (name: String, sample_rates: Array[Integer], file_types: Array[String], ?bit_depths: Array[Integer], ?bpm_source: Symbol?, ?bar_multiple: Integer?, ?unsupported_characters: Array[String], ?transliterate_metadata: bool, ?transliterate_paths: bool, ?uppercase_paths: bool) -> void
19
+ def initialize(name:, sample_rates:, file_types:, bit_depths: [], bpm_source: nil, bar_multiple: nil, unsupported_characters: [], transliterate_metadata: false, transliterate_paths: false, uppercase_paths: false)
19
20
  @name = name
20
21
  @sample_rates = sample_rates
21
22
  @bit_depths = bit_depths
@@ -24,6 +25,7 @@ module Wavesync
24
25
  @bar_multiple = bar_multiple
25
26
  @unsupported_characters = unsupported_characters
26
27
  @transliterate_metadata = transliterate_metadata
28
+ @transliterate_paths = transliterate_paths
27
29
  @uppercase_paths = uppercase_paths
28
30
  end
29
31
 
@@ -55,6 +57,7 @@ module Wavesync
55
57
  bar_multiple: attrs['bar_multiple'],
56
58
  unsupported_characters: attrs['unsupported_characters'] || [],
57
59
  transliterate_metadata: attrs['transliterate_metadata'] || false,
60
+ transliterate_paths: attrs['transliterate_paths'] || false,
58
61
  uppercase_paths: attrs['uppercase_paths'] || false
59
62
  )
60
63
  end
@@ -15,6 +15,16 @@ module Wavesync
15
15
  loader.audio >> rhythm.signal
16
16
  rhythm.bpm >> (pool, 'bpm')
17
17
  rhythm.confidence >> (pool, 'confidence')
18
+ rhythm.beats_position >> None
19
+ rhythm.bpm_estimates >> None
20
+ rhythm.bpm_intervals >> None
21
+ rhythm.first_peak_bpm >> None
22
+ rhythm.first_peak_spread >> None
23
+ rhythm.first_peak_weight >> None
24
+ rhythm.second_peak_bpm >> None
25
+ rhythm.second_peak_spread >> None
26
+ rhythm.second_peak_weight >> None
27
+ rhythm.histogram >> None
18
28
  essentia.run(loader)
19
29
  print(json.dumps({'bpm': round(float(pool['bpm'])), 'confidence': round(float(pool['confidence']), 2)}))
20
30
  PYTHON
@@ -3,6 +3,7 @@
3
3
 
4
4
  require 'open3'
5
5
  require 'json'
6
+ require_relative '../timing'
6
7
 
7
8
  module Wavesync
8
9
  class FFMPEG
@@ -52,10 +53,13 @@ module Wavesync
52
53
  #: () -> Hash[String, untyped]
53
54
  def run_probe
54
55
  ffprobe = FFMPEG.binary.sub('ffmpeg', 'ffprobe')
55
- stdout, _stderr, _status = Open3.capture3(
56
- ffprobe, '-v', 'quiet', '-print_format', 'json',
57
- '-show_streams', '-show_format', @file_path
58
- )
56
+ stdout = Timing.current.measure(:probe) do
57
+ out, _stderr, _status = Open3.capture3(
58
+ ffprobe, '-v', 'quiet', '-print_format', 'json',
59
+ '-show_streams', '-show_format', @file_path
60
+ )
61
+ out
62
+ end
59
63
  JSON.parse(stdout)
60
64
  end
61
65
 
@@ -49,12 +49,13 @@ module Wavesync
49
49
  File.open(path, 'a') { |file| file.write(entry) }
50
50
  end
51
51
 
52
- #: (Float seconds) -> void
53
- def self.log_run_time(seconds)
52
+ #: (Float seconds, ?timings: Hash[Symbol, Float]) -> void
53
+ def self.log_run_time(seconds, timings: {})
54
54
  path = log_path
55
55
  return unless path
56
56
 
57
57
  entry = "[#{timestamp}] Run time: #{format_duration(seconds)}\n"
58
+ entry += format_timings(seconds, timings) unless timings.empty?
58
59
  File.open(path, 'a') { |file| file.write(entry) }
59
60
  end
60
61
 
@@ -80,5 +81,18 @@ module Wavesync
80
81
  end
81
82
  end
82
83
  private_class_method :format_duration
84
+
85
+ #: (Float total_seconds, Hash[Symbol, Float] timings) -> String
86
+ def self.format_timings(total_seconds, timings)
87
+ tracked = timings.values.sum
88
+ other = [total_seconds - tracked, 0.0].max
89
+ rows = timings.merge(other: other).select { |_, value| value.positive? }
90
+ label_width = rows.keys.map { |key| key.to_s.length }.max || 0
91
+ rows.map do |bucket, seconds|
92
+ percentage = total_seconds.positive? ? (seconds / total_seconds * 100) : 0.0
93
+ format(" %-#{label_width}s %6.1fs %5.1f%%\n", bucket, seconds, percentage)
94
+ end.join
95
+ end
96
+ private_class_method :format_timings
83
97
  end
84
98
  end