motion-speech 0.0.1
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/README.md +129 -0
- data/lib/motion-speech.rb +15 -0
- data/lib/motion/speech/event_block.rb +23 -0
- data/lib/motion/speech/speaker.rb +114 -0
- data/lib/motion/speech/version.rb +5 -0
- data/spec/event_block_spec.rb +27 -0
- data/spec/helpers/speakable.rb +5 -0
- data/spec/speaker_spec.rb +166 -0
- metadata +70 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 3d7d171c14e64dd90873710ad94277b660dd6c33
|
4
|
+
data.tar.gz: 4e6522282418bc358c673fc365a3ac9ed1eada5f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6547b64dde2bb648db4fc586b495c019381fd4069e660114d7acd176ee6010ebeec06d53c8ed500a0f538aac0006257367b7c230e70cd3361f0e9402dd9d5bfd
|
7
|
+
data.tar.gz: b6344702f82cf2570820623067cf0480f1f09dc24065dafd7e8ff3293b29983331db3a127e4d8e5562d3ef2fb1e55f4f3ae73873bb89ff37b7e015048d7e37fb
|
data/README.md
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
# motion-speech
|
2
|
+
Provides a simpler interface to using the AVSpeechSynthesizer related classes available natively in iOS 7.
|
3
|
+
|
4
|
+
## Installation
|
5
|
+
|
6
|
+
Add the following to your project's Gemfile to work with bundler:
|
7
|
+
|
8
|
+
```ruby
|
9
|
+
gem 'motion-speech'
|
10
|
+
```
|
11
|
+
|
12
|
+
Install with bundler:
|
13
|
+
|
14
|
+
```shell
|
15
|
+
bundle install
|
16
|
+
```
|
17
|
+
|
18
|
+
### AVFoundation
|
19
|
+
This gem includes the `AVFoundation` framework into your project automatically for you.
|
20
|
+
|
21
|
+
## Usage
|
22
|
+
Some basic usage examples are listed below.
|
23
|
+
|
24
|
+
```ruby
|
25
|
+
# Speak a sentence
|
26
|
+
Motion::Speech::Speaker.speak "Getting started with speech"
|
27
|
+
|
28
|
+
# Control the rate of speech
|
29
|
+
Motion::Speech::Speaker.speak "Getting started with speech", rate: 1
|
30
|
+
|
31
|
+
# Pass a block to be called when the speech is completed
|
32
|
+
Motion::Speech::Speaker.speak "Getting started with speech" do
|
33
|
+
puts "completed the utterance"
|
34
|
+
end
|
35
|
+
```
|
36
|
+
|
37
|
+
## Advanced Usage
|
38
|
+
There are several more advanced examples that you can follow below, allowing more customization of the utterance playback including voices (coming soon) as well as contriving arbitrary objects for speech.
|
39
|
+
|
40
|
+
### Speakable
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
class Name < String
|
44
|
+
def to_speakable
|
45
|
+
"My name is #{self}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
my_name = Name.new("Matt Brewer")
|
50
|
+
Motion::Speech::Speaker.speak my_name
|
51
|
+
# => "My name is Matt Brewer" spoken
|
52
|
+
```
|
53
|
+
|
54
|
+
### Callbacks as blocks
|
55
|
+
This will look somewhat familiar to Rails developers, can work off a system of block callbacks for further control.
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
Motion::Speech::Speaker.speak "lorem" do |events|
|
59
|
+
events.start do |speaker|
|
60
|
+
puts "started speaking: '#{speaker.message}'"
|
61
|
+
end
|
62
|
+
|
63
|
+
events.finish do |speaker|
|
64
|
+
puts "finished speaking: '#{speaker.message}'"
|
65
|
+
end
|
66
|
+
|
67
|
+
events.pause do |speaker|
|
68
|
+
puts "paused while speaking: '#{speaker.message}'"
|
69
|
+
end
|
70
|
+
|
71
|
+
events.cancel do |speaker|
|
72
|
+
puts "canceled while speaking: '#{speaker.message}'"
|
73
|
+
end
|
74
|
+
|
75
|
+
events.resume do |speaker|
|
76
|
+
puts "resumed speaking: '#{speaker.message}'"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
```
|
80
|
+
|
81
|
+
### Using methods for callbacks
|
82
|
+
This is not unique to RubyMotion, but you can easily grab a block from a method on your class to use as a callback here too.
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
class SomeController < UIViewController
|
86
|
+
|
87
|
+
def tapped_button(*args)
|
88
|
+
Motion::Speech::Speaker.speak "lorem" do |events|
|
89
|
+
events.start &method(:lock_ui)
|
90
|
+
events.finish &method(:unlock_ui)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def lock_iu(speaker)
|
97
|
+
self.view.userInteractionEnabled = false
|
98
|
+
end
|
99
|
+
|
100
|
+
def unlock_ui(speaker)
|
101
|
+
self.view.userInteractionEnabled = true
|
102
|
+
end
|
103
|
+
end
|
104
|
+
```
|
105
|
+
|
106
|
+
### Controlling playback
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
speaker = Motion::Speech::Speaker.speak "lorem"
|
110
|
+
|
111
|
+
# pausing playback accepts symbols or actual structs
|
112
|
+
speaker.pause :word
|
113
|
+
speaker.pause :immediate
|
114
|
+
speaker.pause AVSpeechBoundaryImmediate
|
115
|
+
|
116
|
+
speaker.paused?
|
117
|
+
=> true
|
118
|
+
|
119
|
+
speaker.speaking?
|
120
|
+
=> false
|
121
|
+
|
122
|
+
# stopping playback accepts symbols or actual structs
|
123
|
+
speaker.stop :word
|
124
|
+
speaker.stop :immediate
|
125
|
+
speaker.stop AVSpeechBoundaryImmediate
|
126
|
+
|
127
|
+
# resume playback
|
128
|
+
speaker.resume
|
129
|
+
```
|
@@ -0,0 +1,15 @@
|
|
1
|
+
unless defined?(Motion::Project::Config)
|
2
|
+
raise "This file must be required within a RubyMotion project Rakefile."
|
3
|
+
end
|
4
|
+
|
5
|
+
lib_dir_path = File.dirname(File.expand_path(__FILE__))
|
6
|
+
Motion::Project::App.setup do |app|
|
7
|
+
gem_files = Dir.glob(File.join(lib_dir_path, "motion/**/*.rb"))
|
8
|
+
app.files.unshift(gem_files).flatten!
|
9
|
+
|
10
|
+
if app.deployment_target.to_f < 7.0
|
11
|
+
warn "AVSpeechSynthesizer and friends are only available in iOS 7.0+"
|
12
|
+
end
|
13
|
+
|
14
|
+
app.frameworks += %w(AVFoundation)
|
15
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Motion
|
2
|
+
module Speech
|
3
|
+
class EventBlock
|
4
|
+
|
5
|
+
Events = %w(start finish cancel pause resume).freeze
|
6
|
+
|
7
|
+
Events.each do |method|
|
8
|
+
define_method method do |*args, &block|
|
9
|
+
if !block.nil?
|
10
|
+
instance_variable_set("@#{method}_block", block)
|
11
|
+
else
|
12
|
+
instance_variable_get("@#{method}_block")
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(event, speaker)
|
18
|
+
block = send(event)
|
19
|
+
block.call(speaker) unless block.nil?
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module Motion
|
2
|
+
module Speech
|
3
|
+
class Speaker
|
4
|
+
attr_reader :message, :options
|
5
|
+
|
6
|
+
MultipleCallsToSpeakError = Class.new(StandardError)
|
7
|
+
|
8
|
+
def self.speak(*args, &block)
|
9
|
+
new(*args, &block).speak
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize(speakable, options={}, &block)
|
13
|
+
@message = string_from_speakable(speakable)
|
14
|
+
@options = options
|
15
|
+
@spoken = false
|
16
|
+
|
17
|
+
if block_given?
|
18
|
+
if block.arity == 0
|
19
|
+
events.finish &block
|
20
|
+
elsif block.arity == 1
|
21
|
+
block.call events
|
22
|
+
else
|
23
|
+
raise ArgumentError, 'block must accept either 0 or 1 arguments'
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def speak
|
29
|
+
raise MultipleCallsToSpeakError if @spoken
|
30
|
+
|
31
|
+
synthesizer.speakUtterance utterance
|
32
|
+
@spoken = true
|
33
|
+
self
|
34
|
+
end
|
35
|
+
|
36
|
+
def pause(boundary)
|
37
|
+
synthesizer.pauseSpeakingAtBoundary boundary_from_symbol(boundary)
|
38
|
+
end
|
39
|
+
|
40
|
+
def stop(boundary)
|
41
|
+
synthesizer.stopSpeakingAtBoundary boundary_from_symbol(boundary)
|
42
|
+
end
|
43
|
+
|
44
|
+
def resume
|
45
|
+
synthesizer.continueSpeaking
|
46
|
+
end
|
47
|
+
|
48
|
+
def paused?
|
49
|
+
synthesizer.paused?
|
50
|
+
end
|
51
|
+
|
52
|
+
def speaking?
|
53
|
+
synthesizer.speaking?
|
54
|
+
end
|
55
|
+
|
56
|
+
def utterance
|
57
|
+
return @utterance unless @utterance.nil?
|
58
|
+
|
59
|
+
@utterance = AVSpeechUtterance.speechUtteranceWithString(message)
|
60
|
+
@utterance.rate = options.fetch(:rate, 0.15)
|
61
|
+
@utterance
|
62
|
+
end
|
63
|
+
|
64
|
+
def synthesizer
|
65
|
+
@synthesizer ||= AVSpeechSynthesizer.new.tap { |s| s.delegate = self }
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def speechSynthesizer(s, didFinishSpeechUtterance: utterance)
|
71
|
+
events.call :finish, self
|
72
|
+
end
|
73
|
+
|
74
|
+
def speechSynthesizer(s, didStartSpeechUtterance: utterance)
|
75
|
+
events.call :start, self
|
76
|
+
end
|
77
|
+
|
78
|
+
def speechSynthesizer(s, didCancelSpeechUtterance: utterance)
|
79
|
+
events.call :cancel, self
|
80
|
+
end
|
81
|
+
|
82
|
+
def speechSynthesizer(s, didPauseSpeechUtterance: utterance)
|
83
|
+
events.call :pause, self
|
84
|
+
end
|
85
|
+
|
86
|
+
def speechSynthesizer(s, didContinueSpeechUtterance: utterance)
|
87
|
+
events.call :resume, self
|
88
|
+
end
|
89
|
+
|
90
|
+
def events
|
91
|
+
@events ||= EventBlock.new
|
92
|
+
end
|
93
|
+
|
94
|
+
def string_from_speakable(speakable)
|
95
|
+
if speakable.respond_to?(:to_speakable)
|
96
|
+
speakable.to_speakable
|
97
|
+
else
|
98
|
+
speakable
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def boundary_from_symbol(sym)
|
103
|
+
case sym
|
104
|
+
when :word
|
105
|
+
AVSpeechBoundaryWord
|
106
|
+
when :immediate
|
107
|
+
AVSpeechBoundaryImmediate
|
108
|
+
when Fixnum
|
109
|
+
sym
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
describe Motion::Speech::EventBlock do
|
2
|
+
before do
|
3
|
+
@event = Motion::Speech::EventBlock.new
|
4
|
+
end
|
5
|
+
|
6
|
+
it "has has all the events we need" do
|
7
|
+
%w(start finish cancel pause resume).each do |e|
|
8
|
+
Motion::Speech::EventBlock::Events.should.include e
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
it "exposes methods for 'start' and 'finish'" do
|
13
|
+
@event.should.respond_to "start"
|
14
|
+
@event.should.respond_to "finish"
|
15
|
+
end
|
16
|
+
|
17
|
+
it "exposes methods to retrieve the stored block" do
|
18
|
+
@event.start { true }
|
19
|
+
@event.start.should.be.instance_of Proc
|
20
|
+
@event.start.call.should.be.true
|
21
|
+
end
|
22
|
+
|
23
|
+
it "allows you to call an event" do
|
24
|
+
@event.start { true }
|
25
|
+
@event.call(:start, nil).should.be.true
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
describe Motion::Speech::Speaker do
|
2
|
+
|
3
|
+
describe 'initialization' do
|
4
|
+
|
5
|
+
it "creates new instance when calling #speak" do
|
6
|
+
speaker = Motion::Speech::Speaker.speak "lorem"
|
7
|
+
speaker.should.be.instance_of Motion::Speech::Speaker
|
8
|
+
end
|
9
|
+
|
10
|
+
it "stores message" do
|
11
|
+
speaker = Motion::Speech::Speaker.new "lorem"
|
12
|
+
speaker.message.should.be.equal "lorem"
|
13
|
+
end
|
14
|
+
|
15
|
+
it "accepts an options hash" do
|
16
|
+
speaker = Motion::Speech::Speaker.new "lorem", key: :value
|
17
|
+
speaker.options.should.be.equal key: :value
|
18
|
+
end
|
19
|
+
|
20
|
+
it "calls #to_speakable on sentence if supported" do
|
21
|
+
sentence = Speakable.new("lorem")
|
22
|
+
speaker = Motion::Speech::Speaker.new sentence
|
23
|
+
speaker.message.should.be.equal sentence.to_speakable
|
24
|
+
end
|
25
|
+
|
26
|
+
it "raises exception if you make multiple calls to #speak" do
|
27
|
+
speaker = Motion::Speech::Speaker.new "lorem"
|
28
|
+
speaker.speak
|
29
|
+
|
30
|
+
should.raise(Motion::Speech::Speaker::MultipleCallsToSpeakError) do
|
31
|
+
speaker.speak
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
describe '#utterance' do
|
37
|
+
before do
|
38
|
+
@speaker = Motion::Speech::Speaker.new "lorem"
|
39
|
+
end
|
40
|
+
|
41
|
+
it "returns an AVSpeechUtterance instance" do
|
42
|
+
@speaker.utterance.should.be.instance_of AVSpeechUtterance
|
43
|
+
end
|
44
|
+
|
45
|
+
describe '#rate' do
|
46
|
+
|
47
|
+
it "sets the speech rate to a reasonable default" do
|
48
|
+
@speaker.utterance.rate.should.be.equal 0.15
|
49
|
+
end
|
50
|
+
|
51
|
+
it "allows me to override the rate" do
|
52
|
+
speaker = Motion::Speech::Speaker.new "lorem", rate: 0.75
|
53
|
+
speaker.utterance.rate.should.be.equal 0.75
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
describe '#synthesizer' do
|
59
|
+
before do
|
60
|
+
@speaker = Motion::Speech::Speaker.new "lorem"
|
61
|
+
end
|
62
|
+
|
63
|
+
it "returns an AVSpeechSynthesizer" do
|
64
|
+
@speaker.synthesizer.should.be.instance_of AVSpeechSynthesizer
|
65
|
+
end
|
66
|
+
|
67
|
+
it "is the synthesizer delegate" do
|
68
|
+
@speaker.synthesizer.delegate.should.be.equal @speaker
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe 'events' do
|
73
|
+
|
74
|
+
describe 'block without arguments' do
|
75
|
+
before do
|
76
|
+
@speaker = Motion::Speech::Speaker.new("lorem") { @called_block = true }
|
77
|
+
end
|
78
|
+
|
79
|
+
it "calls the block on completion" do
|
80
|
+
@called_block.should.be.nil
|
81
|
+
|
82
|
+
# Send delegate message immediately, AVFoundation is tested
|
83
|
+
@speaker.send 'speechSynthesizer:didFinishSpeechUtterance:', @speaker.synthesizer, @speaker.utterance
|
84
|
+
@called_block.should.be.true
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
describe 'block with too many arguments' do
|
89
|
+
it "raises an exception" do
|
90
|
+
should.raise(ArgumentError) do
|
91
|
+
Motion::Speech::Speaker.new("lorem") do |arg1, arg2|
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
describe 'block with 1 argument' do
|
98
|
+
|
99
|
+
it "calls the start block" do
|
100
|
+
speaker = Motion::Speech::Speaker.new "lorem" do |events|
|
101
|
+
events.start { @called_block = true }
|
102
|
+
end
|
103
|
+
|
104
|
+
@called_block = nil
|
105
|
+
@called_block.should.be.nil
|
106
|
+
|
107
|
+
# Send delegate message immediately, AVFoundation is tested
|
108
|
+
speaker.send 'speechSynthesizer:didStartSpeechUtterance:', speaker.synthesizer, speaker.utterance
|
109
|
+
@called_block.should.be.true
|
110
|
+
end
|
111
|
+
|
112
|
+
it "calls the finish block" do
|
113
|
+
speaker = Motion::Speech::Speaker.new "lorem" do |events|
|
114
|
+
events.finish { @called_block = true }
|
115
|
+
end
|
116
|
+
|
117
|
+
@called_block = nil
|
118
|
+
@called_block.should.be.nil
|
119
|
+
|
120
|
+
# Send delegate message immediately, AVFoundation is tested
|
121
|
+
speaker.send 'speechSynthesizer:didFinishSpeechUtterance:', speaker.synthesizer, speaker.utterance
|
122
|
+
@called_block.should.be.true
|
123
|
+
end
|
124
|
+
|
125
|
+
it "calls the paused block" do
|
126
|
+
speaker = Motion::Speech::Speaker.new "lorem" do |events|
|
127
|
+
events.pause { @called_block = true }
|
128
|
+
end
|
129
|
+
|
130
|
+
@called_block = nil
|
131
|
+
@called_block.should.be.nil
|
132
|
+
|
133
|
+
# Send delegate message immediately, AVFoundation is tested
|
134
|
+
speaker.send 'speechSynthesizer:didPauseSpeechUtterance:', speaker.synthesizer, speaker.utterance
|
135
|
+
@called_block.should.be.true
|
136
|
+
end
|
137
|
+
|
138
|
+
it "calls the cancelled block" do
|
139
|
+
speaker = Motion::Speech::Speaker.new "lorem" do |events|
|
140
|
+
events.cancel { @called_block = true }
|
141
|
+
end
|
142
|
+
|
143
|
+
@called_block = nil
|
144
|
+
@called_block.should.be.nil
|
145
|
+
|
146
|
+
# Send delegate message immediately, AVFoundation is tested
|
147
|
+
speaker.send 'speechSynthesizer:didCancelSpeechUtterance:', speaker.synthesizer, speaker.utterance
|
148
|
+
@called_block.should.be.true
|
149
|
+
end
|
150
|
+
|
151
|
+
it "calls the resumed block" do
|
152
|
+
speaker = Motion::Speech::Speaker.new "lorem" do |events|
|
153
|
+
events.resume { @called_block = true }
|
154
|
+
end
|
155
|
+
|
156
|
+
@called_block = nil
|
157
|
+
@called_block.should.be.nil
|
158
|
+
|
159
|
+
# Send delegate message immediately, AVFoundation is tested
|
160
|
+
speaker.send 'speechSynthesizer:didContinueSpeechUtterance:', speaker.synthesizer, speaker.utterance
|
161
|
+
@called_block.should.be.true
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
end
|
166
|
+
end
|
metadata
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: motion-speech
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Matt Brewer
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-03-26 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rake
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
description: Provides a simple interface for using the AVSpeechSynthesizer related
|
28
|
+
classes available natively in iOS 7.
|
29
|
+
email:
|
30
|
+
- matt.brewer@me.com
|
31
|
+
executables: []
|
32
|
+
extensions: []
|
33
|
+
extra_rdoc_files: []
|
34
|
+
files:
|
35
|
+
- README.md
|
36
|
+
- lib/motion-speech.rb
|
37
|
+
- lib/motion/speech/event_block.rb
|
38
|
+
- lib/motion/speech/speaker.rb
|
39
|
+
- lib/motion/speech/version.rb
|
40
|
+
- spec/event_block_spec.rb
|
41
|
+
- spec/helpers/speakable.rb
|
42
|
+
- spec/speaker_spec.rb
|
43
|
+
homepage: https://github.com//motion-speech
|
44
|
+
licenses:
|
45
|
+
- MIT
|
46
|
+
metadata: {}
|
47
|
+
post_install_message:
|
48
|
+
rdoc_options: []
|
49
|
+
require_paths:
|
50
|
+
- lib
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: '0'
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '0'
|
61
|
+
requirements: []
|
62
|
+
rubyforge_project: "[none]"
|
63
|
+
rubygems_version: 2.2.2
|
64
|
+
signing_key:
|
65
|
+
specification_version: 4
|
66
|
+
summary: Get your iOS app to talk the easy way.
|
67
|
+
test_files:
|
68
|
+
- spec/event_block_spec.rb
|
69
|
+
- spec/helpers/speakable.rb
|
70
|
+
- spec/speaker_spec.rb
|