radio 0.0.2 → 0.0.3
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/README.md +7 -6
- data/lib/radio.rb +5 -4
- data/lib/radio/controls/civ.rb +284 -0
- data/lib/radio/controls/si570avr.rb +0 -1
- data/lib/radio/filter.rb +33 -34
- data/lib/radio/filters/agc.rb +65 -0
- data/lib/radio/filters/fir.rb +264 -33
- data/lib/radio/filters/iq.rb +142 -0
- data/lib/radio/gif.rb +38 -40
- data/lib/radio/psk31/rx.rb +17 -7
- data/lib/radio/rig.rb +25 -3
- data/lib/radio/rig/rx.rb +11 -6
- data/lib/radio/rig/spectrum.rb +54 -43
- data/lib/radio/rig/ssb.rb +190 -0
- data/lib/radio/rig/ssb.rb_ +150 -0
- data/lib/radio/{input.rb → signal.rb} +16 -12
- data/lib/radio/{inputs → signals}/alsa.rb +20 -23
- data/lib/radio/signals/coreaudio.rb +136 -0
- data/lib/radio/{inputs → signals}/file.rb +8 -8
- data/lib/radio/{inputs → signals}/wav.rb +41 -28
- data/lib/radio/utils/firpm.rb +395 -0
- data/lib/radio/utils/misc.rb +39 -0
- data/lib/radio/utils/window.rb +37 -0
- data/lib/radio/version.rb +1 -1
- data/www/index.erb +81 -12
- data/www/lo.erb +2 -2
- data/www/setup/af.erb +72 -0
- data/www/setup/lo.erb +31 -0
- data/www/setup/{input.erb → rx.erb} +11 -10
- data/www/ssb.erb +20 -0
- data/www/tune.erb +5 -0
- data/www/waterfall.erb +1 -1
- metadata +38 -26
- data/lib/radio/inputs/coreaudio.rb +0 -102
- data/lib/radio/psk31/fir_coef.rb +0 -292
- data/test/test.rb +0 -76
- data/test/wav/bpsk8k.wav +0 -0
- data/test/wav/qpsk8k.wav +0 -0
- data/test/wav/ssb.wav +0 -0
@@ -0,0 +1,150 @@
|
|
1
|
+
# Copyright 2012 The ham21/radio Authors
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
|
16
|
+
class Radio
|
17
|
+
|
18
|
+
class Rig
|
19
|
+
|
20
|
+
module SSB
|
21
|
+
|
22
|
+
def initialize
|
23
|
+
@ssb_semaphore = Mutex.new
|
24
|
+
super
|
25
|
+
end
|
26
|
+
|
27
|
+
def af= output
|
28
|
+
if output and !iq?
|
29
|
+
raise 'requires i/q signal'
|
30
|
+
end
|
31
|
+
old_af_thread = false
|
32
|
+
@ssb_semaphore.synchronize do
|
33
|
+
deregister @af_queue if @af_queue
|
34
|
+
@af_queue = nil
|
35
|
+
old_rate = 0
|
36
|
+
if @af
|
37
|
+
@af.stop
|
38
|
+
old_af_thread = @af_thread
|
39
|
+
old_af_thread.kill if old_af_thread
|
40
|
+
end
|
41
|
+
if @af = output
|
42
|
+
@bfo_filter = bfo_mixer
|
43
|
+
@af_filter = af_generate_filter
|
44
|
+
@agc = Filter.new :agc => true
|
45
|
+
@iq = Filter.new :iq => true
|
46
|
+
|
47
|
+
#TODO need a nicer pattern to force JIT compile
|
48
|
+
@bfo_filter.call(NArray.scomplex(1)) {}
|
49
|
+
@af_filter.call(NArray.sfloat(1)) {}
|
50
|
+
@agc.call(NArray.sfloat(1)) {}
|
51
|
+
@iq.call(NArray.scomplex(1)) {}
|
52
|
+
|
53
|
+
register @af_queue = Queue.new
|
54
|
+
@af_thread = Thread.new &method(:af_thread)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
old_af_thread.join if old_af_thread
|
58
|
+
end
|
59
|
+
|
60
|
+
def tune= freq
|
61
|
+
@ssb_semaphore.synchronize do
|
62
|
+
return unless @af
|
63
|
+
@bfo_filter.decimation_mix = freq / rate
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def set_lsb
|
68
|
+
@ssb_semaphore.synchronize do
|
69
|
+
@bfo_filter.decimation_fir = @lsb_coef if @bfo_filter
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def set_usb
|
74
|
+
@ssb_semaphore.synchronize do
|
75
|
+
@bfo_filter.decimation_fir = @usb_coef if @bfo_filter
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def af_thread
|
82
|
+
begin
|
83
|
+
loop do
|
84
|
+
in_data = @af_queue.pop
|
85
|
+
@ssb_semaphore.synchronize do
|
86
|
+
if @af_filter and @af
|
87
|
+
@bfo_filter.call(in_data) do |iq|
|
88
|
+
@iq.call!(iq) do |iq|
|
89
|
+
@agc.call(iq.real + iq.imag) do |pcm|
|
90
|
+
@af_filter.call(pcm) do |data|
|
91
|
+
@af.out data
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
rescue Exception => e
|
100
|
+
p "ERROR #{e.message}: #{e.backtrace.first}" #TODO logger
|
101
|
+
raise e
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def bfo_mixer
|
106
|
+
rate = self.rate.to_f
|
107
|
+
decimate = rate / 6000
|
108
|
+
unless decimate == decimate.floor
|
109
|
+
raise "unable to filter #{rate} to 6000"
|
110
|
+
end
|
111
|
+
bands = []
|
112
|
+
bands[0] = 0.0 / rate
|
113
|
+
bands[1] = 2400.0 / rate
|
114
|
+
bands[2] = 2800.0 / rate
|
115
|
+
bands[3] = 0.5
|
116
|
+
taps = kaiser_estimate passband:0.01, stopband:0.01, transition:bands[2]-bands[1]
|
117
|
+
p taps
|
118
|
+
fir1 = firpm numtaps: taps, type: :bandpass,
|
119
|
+
bands: bands, desired: [1,1,0,0], weights: [1,2000]
|
120
|
+
fir2 = firpm numtaps: taps, type: :hilbert,
|
121
|
+
bands: bands, desired: [1,1,0,0], weights: [1,2000]
|
122
|
+
@usb_coef = NArray.scomplex fir1.size
|
123
|
+
@usb_coef[true] = fir1.to_a
|
124
|
+
@usb_coef.imag = fir2.to_a
|
125
|
+
@lsb_coef = @usb_coef.conj
|
126
|
+
Filter.new fir:@usb_coef, decimate:decimate, mix:0
|
127
|
+
end
|
128
|
+
|
129
|
+
def af_generate_filter
|
130
|
+
return nil unless @af
|
131
|
+
bands = [0,nil,nil,0.5]
|
132
|
+
bands[1] = 2400.0 / @af.rate
|
133
|
+
bands[2] = 2800.0 / @af.rate
|
134
|
+
taps = kaiser_estimate passband:0.01, stopband:0.01, transition:bands[2]-bands[1]
|
135
|
+
p taps
|
136
|
+
fir = firpm numtaps: taps, type: :bandpass,
|
137
|
+
bands: bands, desired: [1,1,0,0], weights: [1,1000]
|
138
|
+
interpolate = @af.rate.to_f / 6000
|
139
|
+
unless interpolate == interpolate.floor
|
140
|
+
raise "unable to filter 6000 to #{@af.rate}"
|
141
|
+
end
|
142
|
+
Filter.new fir:fir, interpolate:interpolate
|
143
|
+
end
|
144
|
+
|
145
|
+
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
@@ -14,7 +14,7 @@
|
|
14
14
|
|
15
15
|
|
16
16
|
class Radio
|
17
|
-
module
|
17
|
+
module Signal
|
18
18
|
|
19
19
|
# Keep this namespace clean because we search inputs by
|
20
20
|
# finding the classes from Radio::Inputs.constants.
|
@@ -23,17 +23,17 @@ class Radio
|
|
23
23
|
# dependencies. This allows us to present a basic debug screen.
|
24
24
|
def self.status
|
25
25
|
s = {}
|
26
|
-
|
26
|
+
constants.collect do |input_name|
|
27
27
|
s[input_name] = eval(input_name.to_s).status
|
28
28
|
end
|
29
29
|
s
|
30
30
|
end
|
31
31
|
|
32
32
|
# Consolidate all sources from all inputs and add the class name to the keys.
|
33
|
-
def self.
|
33
|
+
def self.devices
|
34
34
|
s = {}
|
35
|
-
|
36
|
-
eval(type.to_s).
|
35
|
+
constants.each do |type|
|
36
|
+
eval(type.to_s).devices.each do |id, source|
|
37
37
|
s[[type, id]] = source
|
38
38
|
end
|
39
39
|
end
|
@@ -41,20 +41,24 @@ class Radio
|
|
41
41
|
end
|
42
42
|
|
43
43
|
# You can't new a module so this switches into the specific class.
|
44
|
-
def self.new
|
45
|
-
|
46
|
-
|
44
|
+
def self.new options
|
45
|
+
type = options.delete :type
|
46
|
+
options[:output] = [options[:output]].flatten if options[:output]
|
47
|
+
options[:input] = [options[:input]].flatten if options[:input]
|
48
|
+
unless constants.include? type.to_sym
|
47
49
|
raise NameError, "uninitialized constant Radio::Input::#{type}"
|
48
50
|
end
|
49
|
-
|
51
|
+
device = eval(type.to_s).new options
|
50
52
|
# Ask for and discard the first sample to report errors here
|
51
53
|
begin
|
52
|
-
|
54
|
+
device.out NArray.scomplex(1) if device.output_channels == 2
|
55
|
+
device.out NArray.sfloat(1) if device.output_channels == 1
|
56
|
+
device.in 1 if device.input_channels > 0
|
53
57
|
rescue Exception => e
|
54
|
-
|
58
|
+
device.stop
|
55
59
|
raise e
|
56
60
|
end
|
57
|
-
|
61
|
+
device
|
58
62
|
end
|
59
63
|
|
60
64
|
end
|
@@ -20,13 +20,13 @@ end
|
|
20
20
|
|
21
21
|
|
22
22
|
class Radio
|
23
|
-
module
|
23
|
+
module Signal
|
24
24
|
|
25
25
|
class ALSA
|
26
26
|
|
27
27
|
def self.status
|
28
28
|
if defined? ::ALSA::PCM
|
29
|
-
return "Loaded: %d
|
29
|
+
return "Loaded: %d devices" % devices.count
|
30
30
|
end
|
31
31
|
unless defined? @is_linux
|
32
32
|
@is_linux = (`uname`.strip == 'Linux') rescue false
|
@@ -42,7 +42,7 @@ class Radio
|
|
42
42
|
CP_REGEX = /:\s*(capture|playback)\s*(\d+)\s*$/
|
43
43
|
CD_REGEX = /^(\d+)-(\d+):\s*/
|
44
44
|
|
45
|
-
def self.
|
45
|
+
def self.devices
|
46
46
|
return {} unless defined? ::ALSA::PCM
|
47
47
|
# Funky read to get around old Linux bug
|
48
48
|
# http://kerneltrap.org/mailarchive/git-commits-head/2009/4/17/5510664
|
@@ -63,31 +63,37 @@ class Radio
|
|
63
63
|
result[device] = {
|
64
64
|
name: line,
|
65
65
|
rates: [params.sample_rate],
|
66
|
-
|
66
|
+
input: params.channels,
|
67
|
+
output: 0
|
67
68
|
}
|
68
69
|
end rescue nil
|
69
70
|
end
|
70
71
|
result
|
71
72
|
end
|
72
73
|
|
73
|
-
def initialize
|
74
|
+
def initialize options
|
74
75
|
@stream = ::ALSA::PCM::Capture.new
|
75
|
-
@stream.open id
|
76
|
-
|
77
|
-
|
76
|
+
@stream.open options[:id]
|
77
|
+
if input = options[:input]
|
78
|
+
@channel_i = input[0]
|
79
|
+
@channel_q = input[1]
|
80
|
+
end
|
78
81
|
end
|
79
82
|
|
80
83
|
def rate
|
81
84
|
@stream.hardware_parameters.sample_rate
|
82
85
|
end
|
83
86
|
|
84
|
-
def
|
87
|
+
def input_channels
|
85
88
|
return 2 if @channel_q and @stream.hardware_parameters.channels > 1
|
86
89
|
1
|
87
90
|
end
|
88
91
|
|
89
|
-
def
|
90
|
-
|
92
|
+
def output_channels
|
93
|
+
0
|
94
|
+
end
|
95
|
+
|
96
|
+
def in samples
|
91
97
|
out=nil
|
92
98
|
buf_size = @stream.hw_params.buffer_size_for(samples)
|
93
99
|
FFI::MemoryPointer.new(:char, buf_size) do |buffer|
|
@@ -96,12 +102,13 @@ class Radio
|
|
96
102
|
NArray.to_na(out, NArray::SINT).to_f.div! 32767
|
97
103
|
end
|
98
104
|
stream_channels = @stream.hardware_parameters.channels
|
99
|
-
|
105
|
+
sample_size = buf_size / samples / stream_channels
|
106
|
+
out = case sample_size
|
100
107
|
when 1 then NArray.to_na(out,NArray::BYTE).to_f.collect!{|v|(v-128)/127}
|
101
108
|
when 2 then NArray.to_na(out,NArray::SINT).to_f.div! 32767
|
102
109
|
# when 3 then NArray.to_na(d,NArray::???).to_f.collect!{|v|(v-8388608)/8388607}
|
103
110
|
else
|
104
|
-
raise "Unsupported sample size: #{@
|
111
|
+
raise "Unsupported sample size: #{@sample_size}"
|
105
112
|
end
|
106
113
|
return out if channels == 1 and stream_channels == 1
|
107
114
|
out.reshape! stream_channels, out.size/stream_channels
|
@@ -123,13 +130,3 @@ class Radio
|
|
123
130
|
|
124
131
|
end
|
125
132
|
end
|
126
|
-
|
127
|
-
|
128
|
-
if $0 == __FILE__
|
129
|
-
|
130
|
-
require 'narray'
|
131
|
-
a = Radio::Input::ALSA
|
132
|
-
i = a.new 'default', 44100, 0, nil
|
133
|
-
p i.call 8
|
134
|
-
|
135
|
-
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# Copyright 2012 The ham21/radio Authors
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
|
16
|
+
class Radio
|
17
|
+
module Signal
|
18
|
+
|
19
|
+
class CoreAudio
|
20
|
+
|
21
|
+
VERSION = '>= 0.0.9'
|
22
|
+
BUFFER = 0.3 #seconds of buffer
|
23
|
+
JITTER = 0.6 #percent of buffer for output adjust
|
24
|
+
|
25
|
+
begin
|
26
|
+
gem 'coreaudio', VERSION
|
27
|
+
require 'coreaudio'
|
28
|
+
rescue LoadError => e
|
29
|
+
@bad_coreaudio = gem 'coreaudio'
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.status
|
33
|
+
if defined? ::CoreAudio
|
34
|
+
return "Loaded: %d devices" % devices.count
|
35
|
+
end
|
36
|
+
unless defined? @is_darwin
|
37
|
+
@is_darwin = (`uname`.strip == 'Darwin') rescue false
|
38
|
+
end
|
39
|
+
return 'Unsupported: requires Apple OS' unless @is_darwin
|
40
|
+
return 'Unavailable: gem update coreaudio '+ VERSION.dump if @bad_coreaudio
|
41
|
+
return 'Unavailable: gem install coreaudio'
|
42
|
+
end
|
43
|
+
|
44
|
+
# I don't see a way to automatically set the CoreAudio CODEC rate.
|
45
|
+
# We'll present the nominal_rate as the only option.
|
46
|
+
def self.devices
|
47
|
+
return {} unless defined? ::CoreAudio
|
48
|
+
result = {}
|
49
|
+
::CoreAudio.devices.each do |dev|
|
50
|
+
result[dev.devid] = {
|
51
|
+
name: dev.name,
|
52
|
+
rates: [dev.nominal_rate.to_i],
|
53
|
+
input: dev.input_stream.channels,
|
54
|
+
output: dev.output_stream.channels,
|
55
|
+
}
|
56
|
+
end
|
57
|
+
result
|
58
|
+
end
|
59
|
+
|
60
|
+
attr_reader :input_channels, :output_channels
|
61
|
+
|
62
|
+
def initialize options
|
63
|
+
@device = ::CoreAudio::AudioDevice.new options[:id].to_i
|
64
|
+
@input_channels = @output_channels = 0
|
65
|
+
if @input = options[:input]
|
66
|
+
@input_channels = @input.size
|
67
|
+
buffer_size = @input_channels * rate * BUFFER
|
68
|
+
@input_buf = @device.input_buffer buffer_size
|
69
|
+
@input_buf.start
|
70
|
+
end
|
71
|
+
if @output = options[:output]
|
72
|
+
@output_channels = @output.size
|
73
|
+
buffer_size = @output_channels * rate * BUFFER
|
74
|
+
@output_buf = @device.output_buffer buffer_size
|
75
|
+
@output_buf.start
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# This is called on its own thread in Rig and is expected to block.
|
80
|
+
def in samples
|
81
|
+
# CoreAudio range of -32767..32767 makes easy conversion to -1.0..1.0
|
82
|
+
if @input_channels == 1
|
83
|
+
@input_buf.read(samples)[@input[0],true].to_f.div!(32767)
|
84
|
+
else
|
85
|
+
b = @input_buf.read samples
|
86
|
+
c_out = NArray.scomplex samples
|
87
|
+
c_out[0..-1] = b[@input[0],true].to_f.div!(32767)
|
88
|
+
c_out.imag = b[@input[1],true].to_f.div!(32767)
|
89
|
+
c_out
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def out data
|
94
|
+
resetting = false
|
95
|
+
if @output_buf.dropped_frame > 0
|
96
|
+
p 'filling because frame dropped' #TODO logger
|
97
|
+
resetting = true
|
98
|
+
filler = rate * BUFFER * JITTER - data.size
|
99
|
+
@output_buf << NArray.sfloat(filler) if filler > 0
|
100
|
+
end
|
101
|
+
output_buf_space = @output_buf.space
|
102
|
+
if output_buf_space < data.size
|
103
|
+
p 'dropping some data' #TODO logger
|
104
|
+
# we'll drop all the data that won't fit
|
105
|
+
@drop_data = data.size - output_buf_space
|
106
|
+
# plus enough data to swing back
|
107
|
+
@drop_data += rate * BUFFER * JITTER
|
108
|
+
end
|
109
|
+
out = nil
|
110
|
+
if @drop_data
|
111
|
+
if @drop_data < data.size
|
112
|
+
out = data[@drop_data..-1] * 32767
|
113
|
+
end
|
114
|
+
@drop_data -= data.size
|
115
|
+
@drop_data = nil if @drop_data <= 0
|
116
|
+
else
|
117
|
+
out = data * 32767
|
118
|
+
end
|
119
|
+
@output_buf << out if out
|
120
|
+
@output_buf.reset_dropped_frame if resetting
|
121
|
+
end
|
122
|
+
|
123
|
+
# Once stopped, rig won't attempt starting again on this object.
|
124
|
+
def stop
|
125
|
+
@input_buf.stop if @input_buf
|
126
|
+
@output_buf.stop if @output_buf
|
127
|
+
end
|
128
|
+
|
129
|
+
def rate
|
130
|
+
@device.nominal_rate
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|
134
|
+
|
135
|
+
end
|
136
|
+
end
|
@@ -15,36 +15,37 @@
|
|
15
15
|
|
16
16
|
|
17
17
|
class Radio
|
18
|
-
module
|
18
|
+
module Signal
|
19
19
|
class File
|
20
20
|
|
21
21
|
def self.status
|
22
|
-
"Loaded: %d files found" %
|
22
|
+
"Loaded: %d files found" % devices.size
|
23
23
|
end
|
24
24
|
|
25
|
-
def self.
|
25
|
+
def self.devices
|
26
26
|
result = {}
|
27
27
|
files = Dir.glob ::File.expand_path '../../../../test/wav/**/*.wav', __FILE__
|
28
28
|
files.each do |file|
|
29
29
|
begin
|
30
|
-
f = new file, 0,
|
30
|
+
f = new id:file, input:[0,1]
|
31
31
|
rescue Exception => e
|
32
32
|
next
|
33
33
|
end
|
34
34
|
result[file] = {
|
35
35
|
name: file,
|
36
36
|
rates: [f.rate],
|
37
|
-
|
37
|
+
input: f.input_channels,
|
38
|
+
output: 0
|
38
39
|
}
|
39
40
|
end
|
40
41
|
result
|
41
42
|
end
|
42
43
|
|
43
44
|
# You can load any file, not just the ones in sources.
|
44
|
-
def initialize
|
45
|
+
def initialize options
|
45
46
|
self.class.constants.each do |x|
|
46
47
|
klass = eval x.to_s
|
47
|
-
@reader = klass.new
|
48
|
+
@reader = klass.new options
|
48
49
|
break if @reader
|
49
50
|
end
|
50
51
|
raise 'Unknown format' unless @reader
|
@@ -57,4 +58,3 @@ class Radio
|
|
57
58
|
end
|
58
59
|
end
|
59
60
|
end
|
60
|
-
|