alsa-backup 0.0.8
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/.autotest +4 -0
- data/History.txt +32 -0
- data/Manifest.txt +32 -0
- data/PostInstall.txt +7 -0
- data/README.rdoc +106 -0
- data/Rakefile +65 -0
- data/TODO +3 -0
- data/alsa-backup.gemspec +47 -0
- data/bin/alsa-backup +9 -0
- data/config.sample +91 -0
- data/lib/alsa.rb +299 -0
- data/lib/alsa_backup.rb +37 -0
- data/lib/alsa_backup/cli.rb +56 -0
- data/lib/alsa_backup/core_ext.rb +34 -0
- data/lib/alsa_backup/length_controller.rb +21 -0
- data/lib/alsa_backup/recorder.rb +91 -0
- data/lib/alsa_backup/writer.rb +124 -0
- data/lib/sndfile.rb +138 -0
- data/script/console +10 -0
- data/script/destroy +14 -0
- data/script/generate +14 -0
- data/spec/alsa/pcm_spec.rb +7 -0
- data/spec/alsa_backup/cli_spec.rb +78 -0
- data/spec/alsa_backup/core_ext_spec.rb +42 -0
- data/spec/alsa_backup/recorder_spec.rb +145 -0
- data/spec/alsa_backup/writer_spec.rb +130 -0
- data/spec/alsa_backup_spec.rb +11 -0
- data/spec/fixtures/config_test.rb +3 -0
- data/spec/sndfile/info_spec.rb +38 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +30 -0
- data/tasks/rspec.rake +35 -0
- metadata +139 -0
data/lib/alsa_backup.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
$:.unshift(File.dirname(__FILE__)) unless
|
2
|
+
$:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
|
3
|
+
|
4
|
+
require 'active_support'
|
5
|
+
require 'logger'
|
6
|
+
|
7
|
+
module AlsaBackup
|
8
|
+
VERSION = '0.0.8'
|
9
|
+
|
10
|
+
def self.recorder
|
11
|
+
@recorder ||= AlsaBackup::Recorder.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.config
|
15
|
+
yield self.recorder
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.logger
|
19
|
+
unless @logger
|
20
|
+
@logger = Logger.new(STDOUT)
|
21
|
+
@logger.level = Logger::INFO
|
22
|
+
end
|
23
|
+
|
24
|
+
@logger
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.logger=(logger); @logger = logger; end
|
28
|
+
|
29
|
+
end
|
30
|
+
|
31
|
+
require 'alsa_backup/core_ext'
|
32
|
+
require 'alsa_backup/length_controller'
|
33
|
+
require 'alsa_backup/writer'
|
34
|
+
require 'alsa_backup/recorder'
|
35
|
+
|
36
|
+
|
37
|
+
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'daemons'
|
3
|
+
|
4
|
+
module AlsaBackup
|
5
|
+
class CLI
|
6
|
+
def self.execute(stdout, *arguments)
|
7
|
+
options = {}
|
8
|
+
mandatory_options = %w()
|
9
|
+
|
10
|
+
OptionParser.new do |opts|
|
11
|
+
opts.banner = <<-BANNER.gsub(/^ /,'')
|
12
|
+
AlsaBackup : continuous recording with alsa
|
13
|
+
|
14
|
+
Usage: #{File.basename($0)} [options]
|
15
|
+
|
16
|
+
Options are:
|
17
|
+
BANNER
|
18
|
+
opts.separator ""
|
19
|
+
opts.on("-f", "--file=FILE", String,
|
20
|
+
"Recording file") { |arg| options[:file] = arg }
|
21
|
+
opts.on("-l", "--length=LENGTH", String,
|
22
|
+
"Length in seconds") { |arg| options[:length] = arg }
|
23
|
+
opts.on("-d", "--directory=DIRECTORY", String,
|
24
|
+
"Base directory") { |arg| options[:directory] = arg }
|
25
|
+
opts.on("-c", "--config=CONFIG", String,
|
26
|
+
"Configuration file") { |arg| options[:config] = arg }
|
27
|
+
opts.on("-p", "--pid=PID_FILE", String,
|
28
|
+
"File to write the process pid") { |arg| options[:pid] = arg }
|
29
|
+
opts.on("-b", "--background", nil,
|
30
|
+
"Daemonize the process") { |arg| options[:daemonize] = true }
|
31
|
+
opts.on("-h", "--help",
|
32
|
+
"Show this help message.") { stdout.puts opts; exit }
|
33
|
+
opts.parse!(arguments)
|
34
|
+
|
35
|
+
if mandatory_options && mandatory_options.find { |option| options[option.to_sym].nil? }
|
36
|
+
stdout.puts opts; exit
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
load File.expand_path(options[:config]) if options[:config]
|
41
|
+
|
42
|
+
AlsaBackup.recorder.file = options[:file] if options[:file]
|
43
|
+
AlsaBackup.recorder.directory = options[:directory] if options[:directory]
|
44
|
+
|
45
|
+
pid_file = File.expand_path(options[:pid]) if options[:pid]
|
46
|
+
|
47
|
+
Daemonize.daemonize(nil, "alsa-backup") if options[:daemonize]
|
48
|
+
File.write(pid_file, $$) if pid_file
|
49
|
+
|
50
|
+
length = options[:length].to_i if options[:length]
|
51
|
+
AlsaBackup.recorder.start(length)
|
52
|
+
rescue Exception => e
|
53
|
+
AlsaBackup.logger.fatal(e)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
class Time
|
2
|
+
|
3
|
+
def floor(attribute, modulo)
|
4
|
+
actual = self.send(attribute)
|
5
|
+
self.change(attribute => actual - actual%modulo)
|
6
|
+
end
|
7
|
+
|
8
|
+
end
|
9
|
+
|
10
|
+
class File
|
11
|
+
|
12
|
+
def self.suffix_basename(file, suffix)
|
13
|
+
dirname = File.dirname(file)
|
14
|
+
|
15
|
+
dirname =
|
16
|
+
case dirname
|
17
|
+
when "/": "/"
|
18
|
+
when ".": ""
|
19
|
+
else
|
20
|
+
dirname + "/"
|
21
|
+
end
|
22
|
+
|
23
|
+
extension = File.extname(file)
|
24
|
+
dirname +
|
25
|
+
File.basename(file, extension) +
|
26
|
+
suffix +
|
27
|
+
extension
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.write(file, content)
|
31
|
+
File.open(file, "w") { |f| f.puts content }
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module AlsaBackup
|
2
|
+
module LengthController
|
3
|
+
class Loop
|
4
|
+
def continue_after?(frame_count)
|
5
|
+
true
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
class FrameCount
|
10
|
+
attr_reader :frame_count
|
11
|
+
|
12
|
+
def initialize(frame_count)
|
13
|
+
@frame_count = frame_count
|
14
|
+
end
|
15
|
+
|
16
|
+
def continue_after?(frame_count)
|
17
|
+
(@frame_count -= frame_count) > 0
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'alsa'
|
2
|
+
|
3
|
+
module AlsaBackup
|
4
|
+
class Recorder
|
5
|
+
|
6
|
+
def initialize(file = "record.wav")
|
7
|
+
@file = File.basename(file)
|
8
|
+
@directory = File.dirname(file)
|
9
|
+
|
10
|
+
@device = "hw:0"
|
11
|
+
@sample_rate = 44100
|
12
|
+
@channels = 2
|
13
|
+
|
14
|
+
@error_handler = Proc.new { |e| true }
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_accessor :file, :directory, :error_handler
|
18
|
+
attr_accessor :device, :sample_rate, :channels
|
19
|
+
|
20
|
+
def start(seconds_to_record = nil)
|
21
|
+
length_controller = self.length_controller(seconds_to_record)
|
22
|
+
|
23
|
+
open_writer do |writer|
|
24
|
+
open_capture do |capture|
|
25
|
+
capture.read do |buffer, frame_count|
|
26
|
+
writer.write buffer, frame_count*format[:channels]
|
27
|
+
length_controller.continue_after? frame_count
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
rescue Exception => e
|
32
|
+
retry if handle_error(e, seconds_to_record.nil?)
|
33
|
+
end
|
34
|
+
|
35
|
+
def open_writer(&block)
|
36
|
+
writer_options = { :directory => directory, :file => file, :format => format(:format => "wav pcm_16") }
|
37
|
+
writer_options[:on_close] = @on_close if @on_close
|
38
|
+
Writer.open(writer_options, &block)
|
39
|
+
end
|
40
|
+
|
41
|
+
def open_capture(&block)
|
42
|
+
ALSA::PCM::Capture.open(device, format(:sample_format => :s16_le), &block)
|
43
|
+
end
|
44
|
+
|
45
|
+
def handle_error(e, try_to_continue = true)
|
46
|
+
if Interrupt === e or SignalException === e
|
47
|
+
AlsaBackup.logger.debug('recorder interrupted')
|
48
|
+
return false
|
49
|
+
end
|
50
|
+
|
51
|
+
AlsaBackup.logger.error(e)
|
52
|
+
AlsaBackup.logger.debug { e.backtrace.join("\n") }
|
53
|
+
|
54
|
+
if try_to_continue and continue_on_error?(e)
|
55
|
+
return true
|
56
|
+
else
|
57
|
+
raise e
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def continue_on_error?(e)
|
62
|
+
error_handler_response = @error_handler.call(e) if @error_handler
|
63
|
+
|
64
|
+
if error_handler_response
|
65
|
+
sleep_time = Numeric === error_handler_response ? error_handler_response : 5
|
66
|
+
AlsaBackup.logger.warn("sleep #{sleep_time}s before retrying")
|
67
|
+
sleep sleep_time
|
68
|
+
end
|
69
|
+
|
70
|
+
error_handler_response
|
71
|
+
end
|
72
|
+
|
73
|
+
def format(additional_parameters = {})
|
74
|
+
{:sample_rate => sample_rate, :channels => channels}.merge(additional_parameters)
|
75
|
+
end
|
76
|
+
|
77
|
+
def length_controller(seconds_to_record)
|
78
|
+
if seconds_to_record
|
79
|
+
AlsaBackup::LengthController::FrameCount.new format[:sample_rate] * seconds_to_record
|
80
|
+
else
|
81
|
+
AlsaBackup::LengthController::Loop.new
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def on_close(&block)
|
86
|
+
@on_close = block
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
require 'sndfile'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
module AlsaBackup
|
5
|
+
class Writer
|
6
|
+
|
7
|
+
attr_accessor :directory, :file, :format, :on_close_callbacks
|
8
|
+
|
9
|
+
def self.default_format
|
10
|
+
{:sample_rate => 44100, :channels => 2, :format => "wav pcm_16"}
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(options = {})
|
14
|
+
options = {
|
15
|
+
:format => Writer.default_format
|
16
|
+
}.update(options)
|
17
|
+
|
18
|
+
@directory = options[:directory]
|
19
|
+
@file = options[:file]
|
20
|
+
@format = options[:format]
|
21
|
+
|
22
|
+
@on_close_callbacks = [ Proc.new do |file|
|
23
|
+
Writer.delete_empty_file(file)
|
24
|
+
end ]
|
25
|
+
@on_close_callbacks << options[:on_close] if options[:on_close]
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.open(options, &block)
|
29
|
+
writer = Writer.new(options)
|
30
|
+
begin
|
31
|
+
writer.prepare
|
32
|
+
yield writer
|
33
|
+
ensure
|
34
|
+
writer.close
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def prepare
|
39
|
+
# prepare sndfile
|
40
|
+
self.sndfile
|
41
|
+
self
|
42
|
+
end
|
43
|
+
|
44
|
+
def write(*arguments)
|
45
|
+
self.sndfile.write *arguments
|
46
|
+
end
|
47
|
+
|
48
|
+
def close
|
49
|
+
close_file
|
50
|
+
end
|
51
|
+
|
52
|
+
def close_file
|
53
|
+
if @sndfile
|
54
|
+
on_close(@sndfile.path)
|
55
|
+
@sndfile.close
|
56
|
+
end
|
57
|
+
@sndfile = nil
|
58
|
+
end
|
59
|
+
|
60
|
+
def on_close(file)
|
61
|
+
AlsaBackup.logger.info('close current file')
|
62
|
+
@on_close_callbacks.each do |callback|
|
63
|
+
begin
|
64
|
+
callback.call(file)
|
65
|
+
rescue Exception => e
|
66
|
+
AlsaBackup.logger.error("error in on_close callback : #{e}")
|
67
|
+
AlsaBackup.logger.debug { e.backtrace.join("\n") }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def file
|
73
|
+
case @file
|
74
|
+
when Proc
|
75
|
+
@file.call
|
76
|
+
else
|
77
|
+
@file
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def target_file
|
82
|
+
File.join self.directory, self.file
|
83
|
+
end
|
84
|
+
|
85
|
+
def sndfile
|
86
|
+
target_file = self.target_file
|
87
|
+
raise "no recording file" unless target_file
|
88
|
+
|
89
|
+
unless @sndfile and @sndfile.path == target_file
|
90
|
+
close_file
|
91
|
+
|
92
|
+
Writer.rename_existing_file(target_file)
|
93
|
+
AlsaBackup.logger.info{"new file #{File.expand_path target_file}"}
|
94
|
+
|
95
|
+
FileUtils.mkdir_p File.dirname(target_file)
|
96
|
+
@sndfile = Sndfile::File.new(target_file, "w", self.format)
|
97
|
+
end
|
98
|
+
@sndfile
|
99
|
+
end
|
100
|
+
|
101
|
+
def self.delete_empty_file(file)
|
102
|
+
if File.exists?(file) and File.size(file) <= 44
|
103
|
+
AlsaBackup.logger.warn("remove empty file #{file}")
|
104
|
+
File.delete file
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def self.rename_existing_file(file)
|
109
|
+
if File.exists?(file)
|
110
|
+
index = 1
|
111
|
+
|
112
|
+
while File.exists?(new_file = File.suffix_basename(file, "-#{index}"))
|
113
|
+
index += 1
|
114
|
+
|
115
|
+
raise "can't find a free file for #{file}" if index > 1000
|
116
|
+
end
|
117
|
+
|
118
|
+
AlsaBackup.logger.warn "rename existing file #{File.basename(file)} into #{new_file}"
|
119
|
+
File.rename(file, new_file)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
124
|
+
end
|
data/lib/sndfile.rb
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'ffi'
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
module Sndfile
|
6
|
+
|
7
|
+
def self.logger
|
8
|
+
unless @logger
|
9
|
+
@logger = Logger.new(STDERR)
|
10
|
+
@logger.level = Logger::WARN
|
11
|
+
end
|
12
|
+
|
13
|
+
@logger
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.logger=(logger); @logger = logger; end
|
17
|
+
|
18
|
+
class File
|
19
|
+
|
20
|
+
attr_reader :path
|
21
|
+
|
22
|
+
def self.open(path, mode, info)
|
23
|
+
file = self.new(path, mode, info)
|
24
|
+
|
25
|
+
begin
|
26
|
+
yield file
|
27
|
+
ensure
|
28
|
+
file.close
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def initialize(path, mode, info)
|
33
|
+
info = (Hash === info ? Info.new(info) : info)
|
34
|
+
@handle = Sndfile::Native::open path, File.native_mode(mode), info.to_native
|
35
|
+
if @handle.null?
|
36
|
+
raise "Not able to open output file " + self.error
|
37
|
+
end
|
38
|
+
@path = path
|
39
|
+
end
|
40
|
+
|
41
|
+
def write(buffer, frame_count)
|
42
|
+
ALSA.logger.debug { "write #{frame_count} frames in #{path}" }
|
43
|
+
write_count = Sndfile::Native::write_short(@handle, buffer, frame_count)
|
44
|
+
|
45
|
+
unless write_count == frame_count
|
46
|
+
raise self.error
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def close
|
51
|
+
Sndfile::Native::close @handle
|
52
|
+
end
|
53
|
+
|
54
|
+
def error
|
55
|
+
Sndfile::Native::strerror @handle
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.native_mode(mode)
|
59
|
+
case mode
|
60
|
+
when "w": Sndfile::Native::MODE_WRITE
|
61
|
+
else
|
62
|
+
raise "Unknown mode: #{mode}"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
|
68
|
+
class Info
|
69
|
+
|
70
|
+
attr_accessor :sample_rate, :channels, :format
|
71
|
+
|
72
|
+
def initialize(attributes = {})
|
73
|
+
update_attributes(attributes)
|
74
|
+
end
|
75
|
+
|
76
|
+
def update_attributes(attributes)
|
77
|
+
attributes.each_pair { |name, value| send("#{name}=", value) }
|
78
|
+
end
|
79
|
+
|
80
|
+
def to_native
|
81
|
+
Sndfile::Native::Info.new.tap do |native|
|
82
|
+
native[:samplerate] = self.sample_rate
|
83
|
+
native[:channels] = self.channels
|
84
|
+
native[:format] = self.native_format
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def format=(format)
|
89
|
+
@format = Info.normalized_format(format)
|
90
|
+
end
|
91
|
+
|
92
|
+
def native_format
|
93
|
+
self.format.inject(0) do |native_format, format_part|
|
94
|
+
native_format | Sndfile::Native::Format.const_get(format_part.upcase)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def self.normalized_format(format)
|
99
|
+
Array(format).join(' ').downcase.scan(/[a-z0-9_]+/)
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
|
104
|
+
module Native
|
105
|
+
extend FFI::Library
|
106
|
+
ffi_lib "libsndfile.so"
|
107
|
+
|
108
|
+
module Format
|
109
|
+
WAV = 0x010000
|
110
|
+
PCM_16 = 0x0002
|
111
|
+
PCM_24 = 0x0003
|
112
|
+
end
|
113
|
+
|
114
|
+
MODE_READ = 0x10
|
115
|
+
MODE_WRITE = 0x20
|
116
|
+
MODE_RDWR = 0x30
|
117
|
+
|
118
|
+
class Info < FFI::Struct
|
119
|
+
layout(
|
120
|
+
:frames, :int64,
|
121
|
+
:samplerate, :int,
|
122
|
+
:channels, :int,
|
123
|
+
:format, :int,
|
124
|
+
:sections, :int,
|
125
|
+
:seekable, :int
|
126
|
+
)
|
127
|
+
end
|
128
|
+
|
129
|
+
attach_function :open, :sf_open, [ :string, :int, :pointer ], :pointer
|
130
|
+
attach_function :close, :sf_close, [ :pointer ], :int
|
131
|
+
|
132
|
+
# TODO off_t won't work on windows
|
133
|
+
attach_function :write_int, :sf_write_int, [ :pointer, :pointer, :off_t ], :off_t
|
134
|
+
attach_function :write_short, :sf_write_short, [ :pointer, :pointer, :off_t ], :off_t
|
135
|
+
|
136
|
+
attach_function :strerror, :sf_strerror, [ :pointer ], :string
|
137
|
+
end
|
138
|
+
end
|