sound_util 0.1.0

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.
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Codec
5
+ class UnsupportedFormatError < SoundUtil::Error; end
6
+
7
+ @encoders = []
8
+ @decoders = []
9
+
10
+ class << self
11
+ attr_reader :encoders, :decoders
12
+
13
+ def register_encoder(codec_const, *formats)
14
+ encoders << { codec: codec_const, formats: formats.map { |f| f.to_s.downcase } }
15
+ end
16
+
17
+ def register_decoder(codec_const, *formats)
18
+ decoders << { codec: codec_const, formats: formats.map { |f| f.to_s.downcase } }
19
+ end
20
+
21
+ def register_codec(codec_const, *formats)
22
+ register_encoder(codec_const, *formats)
23
+ register_decoder(codec_const, *formats)
24
+ end
25
+
26
+ def supported?(format)
27
+ fmt = format.to_s.downcase
28
+ encoders.any? { |entry| entry[:formats].include?(fmt) && codec_supported?(entry[:codec], fmt) } ||
29
+ decoders.any? { |entry| entry[:formats].include?(fmt) && codec_supported?(entry[:codec], fmt) }
30
+ end
31
+
32
+ def encode(format, wave, codec: nil, **kwargs)
33
+ codec = find_codec(encoders, format, codec)
34
+ codec.encode(format, wave, **kwargs)
35
+ end
36
+
37
+ def decode(format, data, codec: nil, **kwargs)
38
+ codec = find_codec(decoders, format, codec)
39
+ codec.decode(format, data, **kwargs)
40
+ end
41
+
42
+ def encode_io(format, wave, io, codec: nil, **kwargs)
43
+ codec = find_codec(encoders, format, codec)
44
+ if codec.respond_to?(:encode_io)
45
+ codec.encode_io(format, wave, io, **kwargs)
46
+ else
47
+ io << codec.encode(format, wave, **kwargs)
48
+ end
49
+ end
50
+
51
+ def decode_io(format, io, codec: nil, **kwargs)
52
+ codec = find_codec(decoders, format, codec)
53
+ if codec.respond_to?(:decode_io)
54
+ codec.decode_io(format, io, **kwargs)
55
+ else
56
+ codec.decode(format, io.read, **kwargs)
57
+ end
58
+ end
59
+
60
+ def detect(data)
61
+ Magic.detect(data)
62
+ end
63
+
64
+ def detect_io(io)
65
+ Magic.detect_io(io).first
66
+ end
67
+
68
+ private
69
+
70
+ def find_codec(list, format, preferred = nil)
71
+ fmt = format.to_s.downcase
72
+ if preferred
73
+ record = list.find { |entry| entry[:formats].include?(fmt) && entry[:codec].to_s == preferred.to_s }
74
+ raise UnsupportedFormatError, "unsupported format #{format}" unless record
75
+
76
+ codec = const_get(record[:codec])
77
+ if codec.respond_to?(:supported?) && !codec.supported?(fmt.to_sym)
78
+ raise UnsupportedFormatError, "unsupported format #{format}"
79
+ end
80
+
81
+ return codec
82
+ end
83
+
84
+ list.each do |entry|
85
+ next unless entry[:formats].include?(fmt)
86
+
87
+ codec = const_get(entry[:codec])
88
+ next if codec.respond_to?(:supported?) && !codec.supported?(fmt.to_sym)
89
+
90
+ return codec
91
+ end
92
+
93
+ raise UnsupportedFormatError, "unsupported format #{format}"
94
+ end
95
+
96
+ def codec_supported?(codec_const, fmt)
97
+ codec = const_get(codec_const)
98
+ !codec.respond_to?(:supported?) || codec.supported?(fmt.to_sym)
99
+ end
100
+ end
101
+
102
+ autoload :Wav, "sound_util/codec/wav"
103
+
104
+ register_codec :Wav, :wav
105
+ end
106
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Filter
5
+ module Mixin
6
+ def define_immutable_version(*names)
7
+ names.each do |name|
8
+ define_method(name) do |*args, **kwargs, &block|
9
+ dup.tap { |wave| wave.public_send("#{name}!", *args, **kwargs, &block) }
10
+ end
11
+ end
12
+ end
13
+
14
+ def define_mutable_version(*names)
15
+ names.each do |name|
16
+ define_method("#{name}!") do |*args, **kwargs, &block|
17
+ initialize_from_buffer(public_send(name, *args, **kwargs, &block).buffer)
18
+ self
19
+ end
20
+ end
21
+ end
22
+
23
+ module_function :define_immutable_version, :define_mutable_version
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Filter
5
+ module Combine
6
+ def append(other_wave)
7
+ self.class.generate_appended_wave(left: self, right: other_wave)
8
+ end
9
+
10
+ def append!(other_wave)
11
+ wave = append(other_wave)
12
+ initialize_from_buffer(wave.buffer)
13
+ self
14
+ end
15
+
16
+ def mix(other_wave)
17
+ self.class.generate_mixed_wave(left: self, right: other_wave)
18
+ end
19
+
20
+ def mix!(other_wave)
21
+ wave = mix(other_wave)
22
+ initialize_from_buffer(wave.buffer)
23
+ self
24
+ end
25
+
26
+ def stack_channels(other_wave)
27
+ self.class.generate_stacked_wave(primary: self, secondary: other_wave)
28
+ end
29
+
30
+ def stack_channels!(other_wave)
31
+ wave = stack_channels(other_wave)
32
+ initialize_from_buffer(wave.buffer)
33
+ self
34
+ end
35
+
36
+ alias + append
37
+ alias << append!
38
+ alias | mix
39
+ alias & stack_channels
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Filter
5
+ module Fade
6
+ extend Filter::Mixin
7
+
8
+ define_immutable_version :fade_in, :fade_out
9
+
10
+ def fade_in!(seconds: duration)
11
+ apply_fade!(seconds, :in)
12
+ end
13
+
14
+ def fade_out!(seconds: duration)
15
+ apply_fade!(seconds, :out)
16
+ end
17
+
18
+ private
19
+
20
+ def apply_fade!(seconds, direction)
21
+ fade_frames = (seconds * sample_rate).to_i
22
+ fade_frames = [[fade_frames, 1].max, frames].min
23
+
24
+ mutate_frames! do |frame_idx, samples|
25
+ factor = fade_factor(frame_idx, fade_frames, direction)
26
+ samples.map { |sample| encode_value(sample_to_float(sample) * factor) }
27
+ end
28
+ end
29
+
30
+ def fade_factor(frame_idx, fade_frames, direction)
31
+ case direction
32
+ when :in
33
+ return 1.0 if frame_idx >= fade_frames
34
+
35
+ (frame_idx + 1).to_f / fade_frames
36
+ when :out
37
+ remaining = frames - frame_idx
38
+ return 1.0 if remaining > fade_frames
39
+
40
+ [(remaining - 1), 0].max.to_f / fade_frames
41
+ else
42
+ 1.0
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Filter
5
+ module Gain
6
+ extend Filter::Mixin
7
+
8
+ define_immutable_version :gain
9
+
10
+ def gain!(factor)
11
+ mutate_frames! do |_frame_idx, samples|
12
+ samples.map { |sample| encode_value(sample_to_float(sample) * factor) }
13
+ end
14
+ end
15
+
16
+ alias * gain
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Filter
5
+ module Resample
6
+ extend Filter::Mixin
7
+
8
+ define_immutable_version :resample
9
+
10
+ def resample!(new_sample_rate, frames: nil, method: :linear)
11
+ target_rate = Integer(new_sample_rate)
12
+ raise ArgumentError, "new sample rate must be positive" unless target_rate.positive?
13
+
14
+ target_frames = frames ? Integer(frames) : calculate_target_frames(target_rate)
15
+ raise ArgumentError, "target frames must be positive" unless target_frames.positive?
16
+
17
+ return self if target_rate == sample_rate && target_frames == self.frames
18
+
19
+ case method
20
+ when :linear
21
+ perform_linear_resample!(target_rate, target_frames)
22
+ else
23
+ raise ArgumentError, "unsupported resample method: #{method.inspect}"
24
+ end
25
+
26
+ self
27
+ end
28
+
29
+ private
30
+
31
+ def calculate_target_frames(new_sample_rate)
32
+ frames = (duration * new_sample_rate).round
33
+ frames = 1 if frames.zero?
34
+ frames
35
+ end
36
+
37
+ def perform_linear_resample!(target_rate, target_frames)
38
+ if frames.zero?
39
+ initialize_from_buffer(Util.build_buffer(self, channels: channels, frames: target_frames, sample_rate: target_rate))
40
+ @sample_rate = target_rate
41
+ @frames = target_frames
42
+ return
43
+ end
44
+
45
+ ratio = sample_rate.to_f / target_rate
46
+ new_buffer = Util.build_buffer(self, channels: channels, frames: target_frames, sample_rate: target_rate)
47
+
48
+ target_frames.times do |frame_idx|
49
+ source_position = frame_idx * ratio
50
+ left_idx = source_position.floor
51
+ right_idx = [left_idx + 1, frames - 1].min
52
+ t = source_position - left_idx
53
+
54
+ left_frame = buffer.read_frame(left_idx)
55
+ right_frame = buffer.read_frame(right_idx)
56
+
57
+ samples = Array.new(channels) do |channel_idx|
58
+ left = sample_to_float(left_frame[channel_idx])
59
+ right = sample_to_float(right_frame[channel_idx])
60
+ value = if left_idx == right_idx
61
+ left
62
+ else
63
+ left + (right - left) * t
64
+ end
65
+ encode_value(value)
66
+ end
67
+
68
+ new_buffer.write_frame(frame_idx, samples)
69
+ end
70
+
71
+ initialize_from_buffer(new_buffer)
72
+ @sample_rate = target_rate
73
+ @frames = target_frames
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Filter
5
+ autoload :Mixin, "sound_util/filter/_mixin"
6
+ autoload :Gain, "sound_util/filter/gain"
7
+ autoload :Fade, "sound_util/filter/fade"
8
+ autoload :Combine, "sound_util/filter/combine"
9
+ autoload :Resample, "sound_util/filter/resample"
10
+ end
11
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Generator
5
+ module Combine
6
+ def generate_appended_wave(left:, right:)
7
+ Util.ensure_same_kind!(left, right)
8
+ Util.assert_dimensions!(right, channels: left.channels)
9
+
10
+ buffer = build_appended_buffer(left, right)
11
+ Util.build_wave_from_buffer(left, buffer)
12
+ end
13
+
14
+ def generate_mixed_wave(left:, right:)
15
+ Util.ensure_same_kind!(left, right)
16
+ Util.assert_dimensions!(right, channels: left.channels)
17
+
18
+ frames = [left.frames, right.frames].max
19
+ buffer = Util.build_buffer(left, channels: left.channels, frames: frames)
20
+
21
+ info = left.format_info
22
+ zero = Util.zero_frame(left.channels)
23
+
24
+ frames.times do |frame_idx|
25
+ left_frame = frame_idx < left.frames ? left.buffer.read_frame(frame_idx) : zero
26
+ right_frame = frame_idx < right.frames ? right.buffer.read_frame(frame_idx) : zero
27
+
28
+ samples = Array.new(buffer.channels) do |channel_idx|
29
+ mix_sample(left_frame[channel_idx], right_frame[channel_idx], info)
30
+ end
31
+
32
+ buffer.write_frame(frame_idx, samples)
33
+ end
34
+
35
+ Util.build_wave_from_buffer(left, buffer)
36
+ end
37
+
38
+ def generate_stacked_wave(primary:, secondary:)
39
+ Util.ensure_same_kind!(primary, secondary)
40
+
41
+ frames = [primary.frames, secondary.frames].max
42
+ total_channels = primary.channels + secondary.channels
43
+
44
+ buffer = Util.build_buffer(primary, channels: total_channels, frames: frames)
45
+
46
+ primary_zero = Util.zero_frame(primary.channels)
47
+ secondary_zero = Util.zero_frame(secondary.channels)
48
+
49
+ frames.times do |frame_idx|
50
+ primary_frame = frame_idx < primary.frames ? primary.buffer.read_frame(frame_idx) : primary_zero
51
+ secondary_frame = frame_idx < secondary.frames ? secondary.buffer.read_frame(frame_idx) : secondary_zero
52
+
53
+ buffer.write_frame(frame_idx, primary_frame + secondary_frame)
54
+ end
55
+
56
+ Util.build_wave_from_buffer(primary, buffer)
57
+ end
58
+
59
+ private
60
+
61
+ def build_appended_buffer(left, right)
62
+ buffer = Util.build_buffer(left, channels: left.channels, frames: left.frames + right.frames)
63
+
64
+ destination = buffer.io_buffer
65
+ destination.copy(left.buffer.io_buffer, 0)
66
+ destination.copy(right.buffer.io_buffer, left.buffer.size)
67
+ buffer
68
+ end
69
+
70
+ def mix_sample(first, second, info)
71
+ (first + second).clamp(info[:min], info[:max])
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Generator
5
+ module Tone
6
+ DEFAULTS = {
7
+ sample_rate: 44_100,
8
+ channels: 1,
9
+ amplitude: 1.0,
10
+ phase: 0.0,
11
+ format: :s16le
12
+ }.freeze
13
+
14
+ def sine(duration_seconds:, frequency:, **options)
15
+ opts = DEFAULTS.merge(options)
16
+ sample_rate = opts[:sample_rate]
17
+ frames = (duration_seconds * sample_rate).to_i
18
+ new(channels: opts[:channels], sample_rate: sample_rate, frames: frames, format: opts[:format]) do |frame_idx|
19
+ t = frame_idx.to_f / sample_rate
20
+ Math.sin((2.0 * Math::PI * frequency * t) + opts[:phase]) * opts[:amplitude]
21
+ end
22
+ end
23
+
24
+ def silence(duration_seconds:, **options)
25
+ opts = DEFAULTS.merge(options)
26
+ sample_rate = opts[:sample_rate]
27
+ frames = (duration_seconds * sample_rate).to_i
28
+ new(channels: opts[:channels], sample_rate: sample_rate, frames: frames, format: opts[:format])
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Generator
5
+ autoload :Tone, "sound_util/generator/tone"
6
+ autoload :Combine, "sound_util/generator/combine"
7
+ end
8
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module SoundUtil
6
+ module Magic
7
+ MAGIC_HEADERS = {
8
+ wav: %w[RIFF RF64]
9
+ }.freeze
10
+
11
+ module_function
12
+
13
+ def bytes_needed = 12
14
+
15
+ def detect(data)
16
+ return nil unless data && data.bytesize >= bytes_needed
17
+
18
+ chunk_id = data.byteslice(0, 4)
19
+ format = data.byteslice(8, 4)
20
+
21
+ return :wav if MAGIC_HEADERS[:wav].include?(chunk_id) && format == "WAVE"
22
+
23
+ nil
24
+ end
25
+
26
+ def detect_io(io)
27
+ pos = io.pos
28
+ data = io.read(bytes_needed)
29
+ io.seek(pos)
30
+ [detect(data), io]
31
+ rescue Errno::ESPIPE, IOError
32
+ data = io.read(bytes_needed)
33
+ fmt = detect(data)
34
+ prefix = (data || "").b
35
+ combined = prefix + (io.read || "")
36
+ new_io = StringIO.new(combined)
37
+ [fmt, new_io]
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Sink
5
+ module Playback
6
+ FORMAT_FLAGS = {
7
+ u8: "U8",
8
+ s16le: "S16_LE",
9
+ s24le: "S24_LE",
10
+ s32le: "S32_LE",
11
+ f32le: "FLOAT_LE",
12
+ f64le: "FLOAT64_LE"
13
+ }.freeze
14
+
15
+ DEFAULT_COMMAND = lambda do |wave|
16
+ flag = FORMAT_FLAGS[wave.format]
17
+ raise SoundUtil::Error, "unsupported playback format: #{wave.format}" unless flag
18
+
19
+ [
20
+ "aplay",
21
+ "-t", "raw",
22
+ "-f", flag,
23
+ "-c", wave.channels.to_s,
24
+ "-r", wave.sample_rate.to_s,
25
+ "-"
26
+ ]
27
+ end
28
+
29
+ def play(command: nil, io: nil)
30
+ if io
31
+ pipe(io)
32
+ return self
33
+ end
34
+
35
+ cmd = build_command(command)
36
+ IO.popen(cmd, "wb") do |handle|
37
+ pipe(handle)
38
+ handle.close_write
39
+ Process.wait(handle.pid) if handle.respond_to?(:pid)
40
+ end
41
+ self
42
+ rescue Errno::ENOENT
43
+ cmd_display = cmd.is_a?(Array) ? cmd.join(" ") : cmd.to_s
44
+ raise SoundUtil::Error, "playback command not found: #{cmd_display}"
45
+ end
46
+
47
+ private
48
+
49
+ def build_command(command)
50
+ return command unless command.nil?
51
+
52
+ DEFAULT_COMMAND.call(self)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "image_util"
4
+
5
+ module SoundUtil
6
+ module Sink
7
+ module Preview
8
+ DEFAULT_WIDTH = 600
9
+ DEFAULT_HEIGHT = 28
10
+
11
+ def preview(io = $stdout, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, caption: nil)
12
+ renderer = PreviewRenderer.new(self, width: width, height: height, caption: caption)
13
+ rendered = renderer.render
14
+ io.puts(rendered || "[wave preview unavailable]")
15
+ self
16
+ rescue LoadError
17
+ io.puts "[wave preview unavailable]"
18
+ self
19
+ end
20
+
21
+ def preview_image(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, caption: nil)
22
+ PreviewRenderer.new(self, width: width, height: height, caption: caption).image
23
+ end
24
+
25
+ class PreviewRenderer
26
+ BACKGROUND_COLOR = [12, 12, 18, 255].freeze
27
+ AXIS_COLOR = [60, 60, 80, 255].freeze
28
+ CHANNEL_COLORS = [
29
+ [90, 200, 255, 255],
30
+ [255, 140, 220, 255],
31
+ [180, 255, 140, 255]
32
+ ].freeze
33
+ TEXT_COLOR = [235, 235, 235, 255].freeze
34
+
35
+ def initialize(wave, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, caption: nil)
36
+ @wave = wave
37
+ @width = [[width, 16].max, 1000].min
38
+ @height = [[height, 16].max, 64].min
39
+ @caption = caption
40
+ end
41
+
42
+ def render
43
+ ImageUtil::Terminal.output_image($stdin, $stdout, image)
44
+ end
45
+
46
+ def image
47
+ @image ||= build_image
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :wave, :width, :height, :caption
53
+
54
+ def build_image
55
+ img = ImageUtil::Image.new(width, height) { BACKGROUND_COLOR }
56
+ draw_axes(img)
57
+ draw_waveform(img)
58
+ draw_caption(img)
59
+ img
60
+ end
61
+
62
+ def draw_axes(image)
63
+ mid = (height - 1) / 2
64
+ width.times { |x| image[x, mid] = AXIS_COLOR }
65
+ height.times { |y| image[0, y] = AXIS_COLOR }
66
+ end
67
+
68
+ def draw_waveform(image)
69
+ return if wave.frames.zero?
70
+
71
+ mid = (height - 1) / 2.0
72
+ scale = (height - 1) / 2.0
73
+ step = [wave.frames.to_f / width, 1.0].max
74
+
75
+ width.times do |x|
76
+ start_idx = (x * step).floor
77
+ end_idx = [((x + 1) * step).ceil, wave.frames - 1].min
78
+ next if start_idx.negative? || start_idx >= wave.frames
79
+
80
+ wave.channels.times do |channel_idx|
81
+ min_amp = 1.0
82
+ max_amp = -1.0
83
+
84
+ start_idx.upto(end_idx) do |frame_idx|
85
+ sample = wave.send(:sample_to_float, wave.buffer.read_frame(frame_idx)[channel_idx])
86
+ min_amp = sample if sample < min_amp
87
+ max_amp = sample if sample > max_amp
88
+ end
89
+
90
+ top = amplitude_to_y(max_amp, mid, scale)
91
+ bottom = amplitude_to_y(min_amp, mid, scale)
92
+ bottom, top = top, bottom if bottom < top
93
+ color = CHANNEL_COLORS[channel_idx % CHANNEL_COLORS.length]
94
+
95
+ top.upto(bottom) { |y| image[x, y] = color }
96
+
97
+ middle_sample = wave.send(:sample_to_float, wave.buffer.read_frame((start_idx + end_idx) / 2)[channel_idx])
98
+ point_y = amplitude_to_y(middle_sample, mid, scale)
99
+ image[x, point_y] = highlight_color(color)
100
+ end
101
+ end
102
+ end
103
+
104
+ def draw_icon(image)
105
+ base_x = [2, width - 8].min
106
+ base_y = 2
107
+ ICON_COORDS.each do |dx, dy|
108
+ x = base_x + dx
109
+ y = base_y + dy
110
+ next if x >= width || y >= height
111
+
112
+ image[x, y] = ICON_COLOR
113
+ end
114
+ end
115
+
116
+ def draw_caption(image)
117
+ text = caption || Kernel.format("%dch %dHz %d frames %.2gs", wave.channels, wave.sample_rate, wave.frames, wave.duration)
118
+ baseline = height - 8
119
+ baseline = [baseline, 1].max
120
+ image.bitmap_text!(text, 2, baseline, color: TEXT_COLOR)
121
+ end
122
+
123
+ def amplitude_to_y(amplitude, mid, scale)
124
+ y = mid - amplitude * scale
125
+ [[y.round, 0].max, height - 1].min
126
+ end
127
+
128
+ def highlight_color(color)
129
+ dup_color = color.dup
130
+ 3.times { |idx| dup_color[idx] = [[dup_color[idx] + 40, 255].min, 0].max }
131
+ dup_color
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoundUtil
4
+ module Sink
5
+ autoload :Playback, "sound_util/sink/playback"
6
+ autoload :Preview, "sound_util/sink/preview"
7
+ end
8
+ end