alsa-backup 0.0.8
Sign up to get free protection for your applications and to get access to all the features.
- 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
|