wavesync 1.0.0.alpha1 → 1.0.0.alpha3

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.
data/lib/wavesync/cli.rb CHANGED
@@ -1,159 +1,22 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
- require 'optparse'
4
+ require_relative 'commands'
4
5
 
5
6
  module Wavesync
6
7
  class CLI
8
+ #: () -> void
7
9
  def self.start
8
- command = ARGV.first && !ARGV.first.start_with?('-') ? ARGV.shift : 'sync'
10
+ command_name = ARGV.first && !ARGV.first.start_with?('-') ? ARGV.shift : 'sync'
11
+ command_class = Commands::ALL.find { |cmd| command_name == cmd.name }
9
12
 
10
- case command
11
- when 'sync'
12
- start_sync
13
- when 'analyze'
14
- start_analyze
15
- when 'set'
16
- start_set
13
+ if command_class
14
+ command_class.new.run
17
15
  else
18
- puts "Unknown command: #{command}"
19
- puts 'Available commands: sync, analyze, set'
16
+ puts "Unknown command: #{command_name}"
17
+ puts "Available commands: #{Commands::ALL.map(&:name).join(', ')}"
20
18
  exit 1
21
19
  end
22
20
  end
23
-
24
- def self.start_sync
25
- options = {}
26
- parser = OptionParser.new do |opts|
27
- opts.banner = 'Usage: wavesync sync [options]'
28
-
29
- opts.on('-d', '--device NAME', 'Name of device to sync (as defined in config)') do |value|
30
- options[:device] = value
31
- end
32
-
33
- opts.on('-c', '--config PATH', 'Path to wavesync config YAML file') do |value|
34
- options[:config] = value
35
- end
36
-
37
- opts.on('-p', '--pad', 'Pad tracks with silence so total length is a multiple of 64 bars (Octatrack only)') do
38
- options[:pad] = true
39
- end
40
- end
41
-
42
- parser.parse!
43
-
44
- config_path = options[:config] || Wavesync::Config::DEFAULT_PATH
45
- config = load_config(config_path)
46
-
47
- device_configs = config.device_configs
48
- if options[:device]
49
- device_configs = device_configs.select { |device_config| device_config[:name] == options[:device] }
50
- if device_configs.empty?
51
- known = config.device_configs.map { |device_config| device_config[:name] }.join(', ')
52
- puts "Unknown device \"#{options[:device]}\". Devices in config: #{known}"
53
- exit 1
54
- end
55
- end
56
-
57
- device_configs.each do |device_config|
58
- next if Wavesync::Device.find_by(name: device_config[:model])
59
-
60
- supported = Wavesync::Device.all.map(&:name).join(', ')
61
- puts "Unknown device model \"#{device_config[:model]}\" in config. Supported models: #{supported}"
62
- exit 1
63
- end
64
-
65
- scanner = Wavesync::Scanner.new(config.library)
66
-
67
- device_configs.each do |device_config|
68
- scanner.sync(device_config[:path], Wavesync::Device.find_by(name: device_config[:model]),
69
- pad: options[:pad] || false)
70
- end
71
- end
72
-
73
- def self.start_set
74
- subcommand = ARGV.shift
75
-
76
- options = {}
77
- parser = OptionParser.new do |opts|
78
- opts.banner = 'Usage: wavesync set <subcommand> [options]'
79
-
80
- opts.on('-c', '--config PATH', 'Path to wavesync config YAML file') do |value|
81
- options[:config] = value
82
- end
83
- end
84
-
85
- parser.parse!
86
-
87
- config_path = options[:config] || Wavesync::Config::DEFAULT_PATH
88
- config = load_config(config_path)
89
-
90
- case subcommand
91
- when 'create'
92
- name = require_set_name('create')
93
- if Wavesync::Set.exists?(config.library, name)
94
- puts "Set '#{name}' already exists. Use 'wavesync set edit #{name}' to edit it."
95
- exit 1
96
- end
97
- set = Wavesync::Set.new(config.library, name)
98
- Wavesync::SetEditor.new(set, config.library).run
99
- when 'edit'
100
- name = require_set_name('edit')
101
- unless Wavesync::Set.exists?(config.library, name)
102
- puts "Set '#{name}' not found. Use 'wavesync set create #{name}' to create it."
103
- exit 1
104
- end
105
- set = Wavesync::Set.load(config.library, name)
106
- Wavesync::SetEditor.new(set, config.library).run
107
- when 'list'
108
- sets = Wavesync::Set.all(config.library)
109
- if sets.empty?
110
- puts 'No sets found.'
111
- else
112
- sets.each { |s| puts "#{s.name} (#{s.tracks.size} tracks)" }
113
- end
114
- else
115
- puts "Unknown subcommand: #{subcommand || '(none)'}"
116
- puts 'Available subcommands: create, edit, list'
117
- exit 1
118
- end
119
- end
120
-
121
- def self.load_config(path)
122
- Wavesync::Config.load(path)
123
- rescue Wavesync::ConfigError => e
124
- puts "Configuration error: #{e.message}"
125
- exit 1
126
- end
127
-
128
- def self.require_set_name(subcommand)
129
- name = ARGV.shift
130
- unless name
131
- puts "Usage: wavesync set #{subcommand} <name>"
132
- exit 1
133
- end
134
- name
135
- end
136
-
137
- def self.start_analyze
138
- options = {}
139
- parser = OptionParser.new do |opts|
140
- opts.banner = 'Usage: wavesync analyze [options]'
141
-
142
- opts.on('-c', '--config PATH', 'Path to wavesync config YAML file') do |value|
143
- options[:config] = value
144
- end
145
-
146
- opts.on('-f', '--force', 'Overwrite existing BPM values') do
147
- options[:overwrite] = true
148
- end
149
- end
150
-
151
- parser.parse!
152
-
153
- config_path = options[:config] || Wavesync::Config::DEFAULT_PATH
154
- config = load_config(config_path)
155
-
156
- Wavesync::Analyzer.new(config.library).analyze(overwrite: options[:overwrite] || false)
157
- end
158
21
  end
159
22
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'optparse'
5
+
6
+ module Wavesync
7
+ module Commands
8
+ class Analyze < Command
9
+ FORCE_OPTION = Option.new(short: '-f', long: '--force', description: 'Overwrite existing BPM values')
10
+
11
+ self.name = 'analyze'
12
+ self.description = 'Detect and write BPM metadata to library tracks'
13
+ self.options = [FORCE_OPTION].freeze
14
+
15
+ #: () -> void
16
+ def run
17
+ options, config = parse_options(banner: 'Usage: wavesync analyze [options]') do |opts, opts_hash|
18
+ opts.on(*FORCE_OPTION.to_a) { opts_hash[:overwrite] = true }
19
+ end
20
+
21
+ Wavesync::Analyzer.new(config.library).analyze(overwrite: options[:overwrite] || false)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Wavesync
6
+ module Commands
7
+ class Command
8
+ class << self
9
+ attr_accessor :name, :description
10
+ attr_writer :options, :subcommands
11
+
12
+ def options = @options || []
13
+ def subcommands = @subcommands || []
14
+ end
15
+
16
+ #: (banner: String) ?{ (OptionParser, Hash[Symbol, untyped]) -> void } -> [Hash[Symbol, untyped], Config]
17
+ def parse_options(banner:)
18
+ options = {} #: Hash[Symbol, untyped]
19
+ OptionParser.new do |opts|
20
+ opts.banner = banner
21
+ opts.on(*CONFIG_OPTION.to_a) { |value| options[:config] = value }
22
+ yield opts, options if block_given?
23
+ end.parse!
24
+ config_path = options[:config] || Wavesync::Config::DEFAULT_PATH
25
+ config = Commands.load_config(config_path)
26
+ [options, config]
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'optparse'
5
+ require 'rainbow'
6
+
7
+ module Wavesync
8
+ module Commands
9
+ class Help < Command
10
+ self.name = 'help'
11
+ self.description = 'Show this help message'
12
+
13
+ DESCRIPTION_COLUMN = 23
14
+
15
+ #: () -> void
16
+ def run
17
+ subcommand_name = ARGV.shift
18
+
19
+ if subcommand_name
20
+ command = ALL.find { |cmd| subcommand_name == cmd.name }
21
+ if command
22
+ show_command_help(command)
23
+ else
24
+ puts "Unknown command: #{subcommand_name}"
25
+ puts "Available commands: #{ALL.map(&:name).reject { |cmd_name| cmd_name == self.class.name }.join(', ')}"
26
+ exit 1
27
+ end
28
+ else
29
+ show_general_help
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ #: () -> void
36
+ def show_general_help
37
+ puts 'Usage: wavesync [command] [options]'
38
+ puts ''
39
+ puts 'Commands:'
40
+ ALL.each do |command|
41
+ if command.subcommands.any?
42
+ command.subcommands.each { |subcommand| puts format_command_line(subcommand.usage, subcommand.description) }
43
+ else
44
+ puts format_command_line(command.name, command.description)
45
+ command.options.each { |option| puts format_option_line(option, indent: 4) }
46
+ end
47
+ puts ''
48
+ end
49
+ puts 'Options:'
50
+ GLOBAL_OPTIONS.each { |option| puts format_option_line(option, indent: 2) }
51
+ end
52
+
53
+ #: (untyped command) -> void
54
+ def show_command_help(command)
55
+ if command.subcommands.any?
56
+ puts "Usage: wavesync #{command.name} <subcommand> [options]"
57
+ puts ''
58
+ puts 'Subcommands:'
59
+ command.subcommands.each do |subcommand|
60
+ subcommand_key = subcommand.usage.delete_prefix("#{command.name} ")
61
+ puts " #{subcommand_key.ljust(DESCRIPTION_COLUMN - 2)}#{subcommand.description}"
62
+ end
63
+ puts ''
64
+ puts 'Options:'
65
+ GLOBAL_OPTIONS.each { |option| puts " #{option.short}, #{option.long} #{option.description}" }
66
+ else
67
+ OptionParser.new do |opts|
68
+ opts.banner = "Usage: wavesync #{command.name} [options]"
69
+ (command.options + GLOBAL_OPTIONS).each { |option| opts.on(*option.to_a) }
70
+ puts opts
71
+ end
72
+ end
73
+ end
74
+
75
+ #: (String name, String description) -> String
76
+ def format_command_line(name, description)
77
+ " #{name.ljust(DESCRIPTION_COLUMN - 2)}#{description}"
78
+ end
79
+
80
+ #: (untyped option, indent: Integer) -> String
81
+ def format_option_line(option, indent:)
82
+ key = "#{option.short}, #{option.long}"
83
+ Rainbow("#{' ' * indent}#{key.ljust(DESCRIPTION_COLUMN - indent)}#{option.description}").darkgray
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'optparse'
5
+
6
+ module Wavesync
7
+ module Commands
8
+ class Set < Command
9
+ self.name = 'set'
10
+ self.subcommands = [
11
+ Subcommand.new(usage: 'set create NAME', description: 'Create a new track set'),
12
+ Subcommand.new(usage: 'set edit NAME', description: 'Edit an existing track set'),
13
+ Subcommand.new(usage: 'set list', description: 'List all track sets')
14
+ ].freeze
15
+
16
+ #: () -> void
17
+ def run
18
+ subcommand = ARGV.shift
19
+
20
+ _options, config = parse_options(banner: 'Usage: wavesync set <subcommand> [options]')
21
+
22
+ case subcommand
23
+ when 'create'
24
+ name = require_name('create')
25
+ if Wavesync::Set.exists?(config.library, name)
26
+ puts "Set '#{name}' already exists. Use 'wavesync set edit #{name}' to edit it."
27
+ exit 1
28
+ end
29
+ set = Wavesync::Set.new(config.library, name)
30
+ Wavesync::SetEditor.new(set, config.library).run
31
+ when 'edit'
32
+ name = require_name('edit')
33
+ unless Wavesync::Set.exists?(config.library, name)
34
+ puts "Set '#{name}' not found. Use 'wavesync set create #{name}' to create it."
35
+ exit 1
36
+ end
37
+ set = Wavesync::Set.load(config.library, name)
38
+ Wavesync::SetEditor.new(set, config.library).run
39
+ when 'list'
40
+ sets = Wavesync::Set.all(config.library)
41
+ if sets.empty?
42
+ puts 'No sets found.'
43
+ else
44
+ sets.each { |set| puts "#{set.name} (#{set.tracks.size} tracks)" }
45
+ end
46
+ else
47
+ puts "Unknown subcommand: #{subcommand || '(none)'}"
48
+ puts 'Available subcommands: create, edit, list'
49
+ exit 1
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ #: (String subcommand) -> String
56
+ def require_name(subcommand)
57
+ name = ARGV.shift
58
+ unless name
59
+ puts "Usage: wavesync set #{subcommand} <name>"
60
+ exit 1
61
+ end
62
+ name
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'optparse'
5
+
6
+ module Wavesync
7
+ module Commands
8
+ class Sync < Command
9
+ DEVICE_OPTION = Option.new(short: '-d', long: '--device NAME', description: 'Name of device to sync (as defined in config)')
10
+ PAD_OPTION = Option.new(short: '-p', long: '--pad', description: 'Pad tracks with silence so total length is a multiple of 64 bars (Octatrack only)')
11
+
12
+ self.name = 'sync'
13
+ self.description = 'Sync music library to a device'
14
+ self.options = [DEVICE_OPTION, PAD_OPTION].freeze
15
+
16
+ #: () -> void
17
+ def run
18
+ options, config = parse_options(banner: 'Usage: wavesync sync [options]') do |opts, opts_hash|
19
+ opts.on(*DEVICE_OPTION.to_a) { |value| opts_hash[:device] = value }
20
+ opts.on(*PAD_OPTION.to_a) { opts_hash[:pad] = true }
21
+ end
22
+
23
+ device_configs = config.device_configs
24
+ if options[:device]
25
+ device_configs = device_configs.select { |device_config| device_config[:name] == options[:device] }
26
+ if device_configs.empty?
27
+ known = config.device_configs.map { |device_config| device_config[:name] }.join(', ')
28
+ puts "Unknown device \"#{options[:device]}\". Devices in config: #{known}"
29
+ exit 1
30
+ end
31
+ elsif device_configs.size > 1
32
+ device_names = device_configs.map { |device_config| device_config[:name] }
33
+ selected_name = Wavesync::UI.new.select('Select device', device_names)
34
+ device_configs = device_configs.select { |device_config| device_config[:name] == selected_name }
35
+ end
36
+
37
+ device_pairs = device_configs.map do |device_config|
38
+ device = Wavesync::Device.find_by(name: device_config[:model])
39
+ unless device
40
+ supported = Wavesync::Device.all.map(&:name).join(', ')
41
+ puts "Unknown device model \"#{device_config[:model]}\" in config. Supported models: #{supported}"
42
+ exit 1
43
+ end
44
+ [device_config, device]
45
+ end #: Array[untyped]
46
+
47
+ scanner = Wavesync::Scanner.new(config.library)
48
+
49
+ device_pairs.each do |pair|
50
+ device_config = pair[0] #: { name: String, model: String, path: String }
51
+ device = pair[1] #: Wavesync::Device
52
+ scanner.sync(device_config[:path], device, pad: options[:pad] || false)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Wavesync
5
+ module Commands
6
+ Option = Struct.new(:short, :long, :description)
7
+ Subcommand = Struct.new(:usage, :description)
8
+
9
+ CONFIG_OPTION = Option.new(short: '-c', long: '--config PATH', description: 'Path to wavesync config YAML file')
10
+ GLOBAL_OPTIONS = [CONFIG_OPTION].freeze
11
+
12
+ #: (String path) -> Config
13
+ def self.load_config(path)
14
+ Wavesync::Config.load(path)
15
+ rescue Wavesync::ConfigError => e
16
+ puts "Configuration error: #{e.message}"
17
+ exit 1
18
+ end
19
+
20
+ require_relative 'commands/command'
21
+ require_relative 'commands/sync'
22
+ require_relative 'commands/analyze'
23
+ require_relative 'commands/set'
24
+ require_relative 'commands/help'
25
+
26
+ ALL = [Sync, Analyze, Set, Help].freeze
27
+ end
28
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'yaml'
4
5
 
@@ -12,8 +13,10 @@ module Wavesync
12
13
  DEVICE_SUPPORTED_KEYS = %w[name model path].freeze
13
14
  DEVICE_REQUIRED_KEYS = %w[name model path].freeze
14
15
 
15
- attr_reader :library, :device_configs
16
+ attr_reader :library #: String
17
+ attr_reader :device_configs #: Array[{ name: String, model: String, path: String }]
16
18
 
19
+ #: (?String path) -> Config
17
20
  def self.load(path = DEFAULT_PATH)
18
21
  expanded = File.expand_path(path)
19
22
  begin
@@ -26,6 +29,7 @@ module Wavesync
26
29
  new(data)
27
30
  end
28
31
 
32
+ #: (untyped data) -> void
29
33
  def initialize(data)
30
34
  validate!(data)
31
35
  @library = File.expand_path(data['library'])
@@ -37,6 +41,7 @@ module Wavesync
37
41
 
38
42
  private
39
43
 
44
+ #: (untyped data) -> void
40
45
  def validate!(data)
41
46
  raise ConfigError, 'Config file must contain a YAML mapping' unless data.is_a?(Hash)
42
47
 
@@ -52,6 +57,7 @@ module Wavesync
52
57
  raise ConfigError, "'devices' must contain at least one device" if data['devices'].empty?
53
58
  end
54
59
 
60
+ #: (untyped device, Integer index) -> void
55
61
  def validate_device!(device, index)
56
62
  raise ConfigError, "Device #{index + 1} must be a YAML mapping" unless device.is_a?(Hash)
57
63
 
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Wavesync
5
+ class CueChunk
6
+ RIFF_HEADER_SIZE = 12
7
+ CHUNK_ID_SIZE = 4
8
+ CHUNK_SIZE_FIELD_SIZE = 4
9
+ CUE_CHUNK_ID = 'cue '
10
+ LIST_CHUNK_ID = 'LIST'
11
+ ADTL_LIST_TYPE = 'adtl'
12
+ LABL_CHUNK_ID = 'labl'
13
+ DATA_CHUNK_ID = 'data'
14
+ CUE_HEADER_SIZE = 4
15
+ BYTES_PER_CUE_POINT = 24
16
+
17
+ UINT32_LE = 'V'
18
+
19
+ #: (String filepath) -> Array[{identifier: Integer, sample_offset: Integer, label: String?}]
20
+ def self.read(filepath)
21
+ cue_points = [] #: Array[{identifier: Integer, sample_offset: Integer, label: String?}]
22
+ labels = {} #: Hash[Integer, String]
23
+
24
+ File.open(filepath, 'rb') do |file|
25
+ file.seek(RIFF_HEADER_SIZE)
26
+
27
+ until file.eof?
28
+ chunk_id = file.read(CHUNK_ID_SIZE)
29
+ break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
30
+
31
+ chunk_size_bytes = file.read(CHUNK_SIZE_FIELD_SIZE)
32
+ break if chunk_size_bytes.nil?
33
+
34
+ chunk_size = chunk_size_bytes.unpack1(UINT32_LE).to_i
35
+ chunk_data_start = file.tell
36
+
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
44
+ 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
48
+ end
49
+
50
+ chunk_padding = chunk_size.odd? ? 1 : 0
51
+ file.seek(chunk_data_start + chunk_size + chunk_padding)
52
+ end
53
+ end
54
+
55
+ cue_points.map { |cue_point| { identifier: cue_point[:identifier], sample_offset: cue_point[:sample_offset], label: labels[cue_point[:identifier]] } }
56
+ end
57
+
58
+ #: (String source_filepath, String output_filepath, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
59
+ def self.write(source_filepath, output_filepath, cue_points)
60
+ File.open(source_filepath, 'rb') do |input|
61
+ File.open(output_filepath, 'wb') do |output|
62
+ output.write(input.read(RIFF_HEADER_SIZE))
63
+
64
+ until input.eof?
65
+ chunk_id = input.read(CHUNK_ID_SIZE)
66
+ break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
67
+
68
+ chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE)
69
+ break if chunk_size_bytes.nil?
70
+
71
+ chunk_size = chunk_size_bytes.unpack1(UINT32_LE).to_i
72
+ chunk_padding = chunk_size.odd? ? 1 : 0
73
+
74
+ if chunk_id == CUE_CHUNK_ID
75
+ input.read(chunk_size + chunk_padding)
76
+ elsif chunk_id == LIST_CHUNK_ID && chunk_size >= 4
77
+ list_type = input.read(4)
78
+ if list_type == ADTL_LIST_TYPE
79
+ input.read(chunk_size - 4 + chunk_padding)
80
+ else
81
+ output.write(chunk_id)
82
+ output.write(chunk_size_bytes)
83
+ output.write(list_type)
84
+ output.write(input.read(chunk_size - 4 + chunk_padding))
85
+ end
86
+ else
87
+ output.write(chunk_id)
88
+ output.write(chunk_size_bytes)
89
+ output.write(input.read(chunk_size + chunk_padding))
90
+ end
91
+ end
92
+
93
+ unless cue_points.empty?
94
+ write_cue_chunk(output, cue_points)
95
+ labeled_cue_points = cue_points.select { |cue_point| cue_point[:label] }
96
+ write_adtl_chunk(output, labeled_cue_points) if labeled_cue_points.any?
97
+ end
98
+ end
99
+ end
100
+
101
+ update_riff_size(output_filepath)
102
+ end
103
+
104
+ #: (untyped output, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
105
+ def self.write_cue_chunk(output, cue_points)
106
+ chunk_size = CUE_HEADER_SIZE + (cue_points.size * BYTES_PER_CUE_POINT)
107
+ output.write(CUE_CHUNK_ID)
108
+ output.write([chunk_size].pack(UINT32_LE))
109
+ output.write([cue_points.size].pack(UINT32_LE))
110
+ cue_points.each_with_index do |cue_point, index|
111
+ identifier = cue_point[:identifier] || (index + 1)
112
+ output.write([identifier].pack(UINT32_LE))
113
+ output.write([0].pack(UINT32_LE))
114
+ output.write(DATA_CHUNK_ID)
115
+ output.write([0].pack(UINT32_LE))
116
+ output.write([0].pack(UINT32_LE))
117
+ output.write([cue_point[:sample_offset]].pack(UINT32_LE))
118
+ end
119
+ end
120
+
121
+ #: (untyped output, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
122
+ def self.write_adtl_chunk(output, cue_points)
123
+ labl_entries = cue_points.map do |cue_point|
124
+ text = "#{cue_point[:label]}\x00"
125
+ data_size = 4 + text.length
126
+ pad = data_size.odd? ? 1 : 0
127
+ { identifier: cue_point[:identifier], text: text, data_size: data_size, pad: pad }
128
+ end #: Array[{identifier: Integer, text: String, data_size: Integer, pad: Integer}]
129
+
130
+ adtl_data_size = 4 + labl_entries.sum { |entry| 8 + entry[:data_size] + entry[:pad] }
131
+ output.write(LIST_CHUNK_ID)
132
+ output.write([adtl_data_size].pack(UINT32_LE))
133
+ output.write(ADTL_LIST_TYPE)
134
+
135
+ labl_entries.each do |entry|
136
+ output.write(LABL_CHUNK_ID)
137
+ output.write([entry[:data_size]].pack(UINT32_LE))
138
+ output.write([entry[:identifier]].pack(UINT32_LE))
139
+ output.write(entry[:text])
140
+ output.write("\x00") if entry[:pad] == 1
141
+ end
142
+ end
143
+
144
+ #: (untyped file, Integer list_end, Hash[Integer, String] labels) -> void
145
+ def self.read_adtl_labels(file, list_end, labels)
146
+ while file.tell < list_end - 8
147
+ sub_id = file.read(CHUNK_ID_SIZE)
148
+ break if sub_id.nil? || sub_id.length < CHUNK_ID_SIZE
149
+
150
+ sub_size_bytes = file.read(CHUNK_SIZE_FIELD_SIZE)
151
+ break if sub_size_bytes.nil?
152
+
153
+ sub_size = sub_size_bytes.unpack1(UINT32_LE).to_i
154
+ sub_data_start = file.tell
155
+
156
+ if sub_id == LABL_CHUNK_ID && sub_size >= 4
157
+ identifier = file.read(4)&.unpack1(UINT32_LE)&.to_i
158
+ text_size = sub_size - 4
159
+ text = text_size.positive? ? (file.read(text_size) || '') : ''
160
+ labels[identifier] = text.delete("\x00") if identifier
161
+ end
162
+
163
+ sub_padding = sub_size.odd? ? 1 : 0
164
+ file.seek(sub_data_start + sub_size + sub_padding)
165
+ end
166
+ end
167
+
168
+ #: (String filepath) -> void
169
+ def self.update_riff_size(filepath)
170
+ File.open(filepath, 'r+b') do |file|
171
+ file.seek(0, IO::SEEK_END)
172
+ file_size = file.tell
173
+ riff_size = file_size - 8
174
+ file.seek(4)
175
+ file.write([riff_size].pack(UINT32_LE))
176
+ end
177
+ end
178
+ end
179
+ end