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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +95 -0
- data/AGENTS.md +12 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE.txt +21 -0
- data/README.md +158 -0
- data/Rakefile +10 -0
- data/exe/sound_util +7 -0
- data/lib/sound_util/cli.rb +55 -0
- data/lib/sound_util/codec/wav.rb +279 -0
- data/lib/sound_util/codec.rb +106 -0
- data/lib/sound_util/filter/_mixin.rb +26 -0
- data/lib/sound_util/filter/combine.rb +42 -0
- data/lib/sound_util/filter/fade.rb +47 -0
- data/lib/sound_util/filter/gain.rb +19 -0
- data/lib/sound_util/filter/resample.rb +77 -0
- data/lib/sound_util/filter.rb +11 -0
- data/lib/sound_util/generator/combine.rb +75 -0
- data/lib/sound_util/generator/tone.rb +32 -0
- data/lib/sound_util/generator.rb +8 -0
- data/lib/sound_util/magic.rb +40 -0
- data/lib/sound_util/sink/playback.rb +56 -0
- data/lib/sound_util/sink/preview.rb +136 -0
- data/lib/sound_util/sink.rb +8 -0
- data/lib/sound_util/util.rb +86 -0
- data/lib/sound_util/version.rb +5 -0
- data/lib/sound_util/wave/buffer.rb +137 -0
- data/lib/sound_util/wave.rb +457 -0
- data/lib/sound_util.rb +16 -0
- data/sig/sound_util.rbs +2 -0
- metadata +120 -0
|
@@ -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,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
|