E value() {
43 | //noinspection unchecked
44 | return (E) type.cast(argument);
45 | }
46 |
47 | @Override
48 | public String toString() {
49 | return argument.toString();
50 | }
51 | }
52 |
53 | /**
54 | * User to construct a CommandChain
55 | */
56 | public static class Builder {
57 | private final CommandChain chain = new CommandChain();
58 |
59 | /**
60 | * Adds a voice command. If the specified {@code phrase} has a meaning similar to the transcript, {@code job} is run.
61 | *
62 | * There are a few useful special characters that you can use for this command. Let's say you have a voice command
63 | * "kick user John" or "set volume to 100". Each commands takes an argument, "John" in the first case, and "100" in
64 | * the second case. Naturally, you'd want the voice command "kick user John" to also work for "kick user Mark" or the
65 | * voice command "set volume 100" to also work for "set volume 50". To facilitate this, you can use the following
66 | * special character sequences:
67 | *
68 | * %s - matches a string argument
69 | * %i - matches an integer argument
70 | * %d - matches a double argument
71 | *
72 | * So for example, in our present situation of kicking a user, you would specify the {@code phrase} as "kick user %s".
73 | * In this case, the voice command will match a user transcript that starts with "kick user" and ends with any string.
74 | * Then, when your VoiceTask is run, it will be given a VoiceArgument with the value of whatever "%s" was. You can
75 | * use as many of these special character sequences as you'd like. For example, "set volume to 100 and kick John" would
76 | * be represented as "set volume to %i and kick %s". If this command matches, your VoiceTask will be called with two
77 | * VoiceArguments, the first a volume integer, and the second a user to kick.
78 | *
79 | * Note: Only use these for very simple arguments that are 1-2 words at most!
80 | *
81 | * Disclaimer: I handle these special characters in a semi-naive way, I could use a much better implementation using neural nets or
82 | * something if you're interested. Let me know if it gets any really trivial cases wrong and I hope it works decently well for you!
83 | *
84 | * @param phrase The phrase that should trigger the command
85 | * @param task The task that should be run when the phrase is detected
86 | * @return Builder object
87 | */
88 | public Builder addPhrase(String phrase, VoiceTask task) {
89 | chain.commands.add(new VoiceCommand(phrase, task));
90 | return this;
91 | }
92 |
93 | /**
94 | * If no voice command matches an incoming transcript with cosine similarity better than {@link CommandChain#minThreshold},
95 | * this fallback task is run. It can be used for things like the bot saying "Sorry, I didn't get that" If null, VocalCord
96 | * won't execute anything.
97 | *
98 | * @param task The task to run when the user said something, but no voice command matched close enough.
99 | * @return Builder object
100 | */
101 | public Builder withFallback(VoiceTask task) {
102 | chain.fallback = task;
103 | return this;
104 | }
105 |
106 | /**
107 | * Adjust the min cosine threshold for a voice command to be even considered to be a possible candidate
108 | *
109 | * @param minThreshold A value between 0 and 1, a value more towards 0 will let a voice transcript still trigger a voice command even if they are vastly different, a value of 1 will only allow essentially perfect matches
110 | * @return Builder object
111 | */
112 | public Builder withMinThreshold(float minThreshold) {
113 | chain.minThreshold = minThreshold;
114 | return this;
115 | }
116 |
117 | /**
118 | * Constructs the command chain object
119 | *
120 | * @return Returns the CommandChain object
121 | */
122 | public CommandChain build() {
123 | if(chain.commands.size() == 0) throw new RuntimeException("Must provide at least one command");
124 |
125 | chain.commandsVector = new PhraseVector(new HashMap<>());
126 |
127 | for(VoiceCommand cmd : chain.commands) {
128 | chain.commandsVector = chain.commandsVector.merge(cmd.phraseVector);
129 | }
130 |
131 | return chain;
132 | }
133 | }
134 |
135 | /*
136 | * Internal code
137 | */
138 |
139 | private CommandChain() {
140 | }
141 |
142 | // finds the best matching candidate voice command
143 | TaskCandidate score(String transcript) {
144 | double maxScore = -1;
145 | int maxIndex = -1;
146 | TaskCandidate leading = null;
147 |
148 | PhraseVector transcriptVector = new PhraseVector(transcript);
149 | PhraseVector docVector = commandsVector.merge(transcriptVector); // the entire document vector
150 |
151 | for(int i = 0; i < commands.size(); i++) {
152 | TaskCandidate candidate = new TaskCandidate(commands.get(i), transcript);
153 |
154 | double score = candidate.score(docVector);
155 | if(score > maxScore) {
156 | maxScore = score;
157 | maxIndex = i;
158 | leading = candidate;
159 | }
160 | }
161 |
162 | if(maxIndex == -1) {
163 | return null;
164 | } else {
165 | return leading;
166 | }
167 | }
168 |
169 | // runs the best matching candidate voice command
170 | void fulfillTaskCandidate(User user, TaskCandidate candidate) {
171 | if(candidate == null || candidate.score < minThreshold && fallback != null) {
172 | fallback.run(user, candidate == null ? "" : candidate.transcript, new VoiceArgument[0]);
173 | } else {
174 | // resolve voice arguments
175 | candidate.command.task.run(user, candidate.transcript, candidate.args);
176 | }
177 | }
178 |
179 | private static class VoiceCommand {
180 | String phrase;
181 | PhraseVector phraseVector;
182 | ArrayList params;
183 | VoiceTask task;
184 |
185 | final ArrayList tokenized = new ArrayList<>();
186 |
187 | public VoiceCommand(String phrase, VoiceTask task) {
188 | this.phrase = phrase;
189 | this.task = task;
190 | this.phraseVector = new PhraseVector(phrase);
191 |
192 | // Compute parameter contexts
193 | // ignore stop words
194 | // ignore other parameters
195 | params = new ArrayList<>();
196 |
197 | ArrayList tokens = new ArrayList<>(Arrays.asList(phrase.replaceAll("[^a-zA-z0-9.% -]", "").trim().toLowerCase().split("\\s+")));
198 |
199 | // remove stop words
200 | tokens.removeIf(STOPS::contains);
201 |
202 | // create a parameter context
203 | for(int i = 0; i < tokens.size(); i++) {
204 | String word = tokens.get(i);
205 |
206 | if(word.equals("%s") || word.equals("%i") || word.equals("%d")) {
207 | params.add(new Param(tokens, i));
208 | } else {
209 | tokenized.add(word);
210 | }
211 | }
212 | }
213 | }
214 |
215 | static class TaskCandidate {
216 | double score; // how closely the TaskCandidate matched a spoken transcript
217 | String transcript; // the exact transcript that was spoken
218 | VoiceCommand command;
219 | VoiceArgument>[] args; // the arguments to the VoiceCommand
220 |
221 | private PhraseVector resolvedVector; // a vector version of the command with all parameters resolved
222 |
223 | public TaskCandidate(VoiceCommand command, String transcript) {
224 | this.transcript = transcript;
225 | this.command = command;
226 |
227 | for(int i = 0; i < 1; i++) {
228 | this.args = resolve(i);
229 |
230 | if(this.args != null) {
231 | break;
232 | }
233 | }
234 |
235 | if(this.args == null) {
236 | resolvedVector = command.phraseVector;
237 | }
238 | }
239 |
240 | private VoiceArgument>[] resolve(int numAllowableErrors) {
241 | /*
242 | * Resolve any parameters in VoiceCommand
243 | */
244 |
245 | // step 1, tokenize transcript
246 | ArrayList tokens = new ArrayList<>(Arrays.asList(transcript.replaceAll("[^a-zA-z0-9. -]", "").trim().toLowerCase().split("\\s+")));
247 |
248 | // step 2, remove all stop words from transcript
249 | tokens.removeIf(STOPS::contains);
250 |
251 | // step 3, generate a "selection list" of parameter candidates, each with a index to where it occurs in tokens
252 | class ParamCandidate {
253 | final String token;
254 | final int position;
255 | public ParamCandidate(String token, int position) {
256 | this.token = token;
257 | this.position = position;
258 | }
259 | }
260 |
261 | ArrayList paramCandidates = new ArrayList<>();
262 |
263 | for(int i = 0; i < tokens.size(); i++) {
264 | if(command.tokenized.contains(tokens.get(i))) continue;
265 |
266 | paramCandidates.add(new ParamCandidate(tokens.get(i), i));
267 | }
268 |
269 | // step 4, loop through parameters in the voice command
270 | VoiceArgument>[] args = new VoiceArgument[command.params.size()];
271 |
272 | int index = 0;
273 |
274 | for(Param p : command.params) {
275 | // The best way to think about this step is that Param "p" is going to take its most
276 | // desired pick from "paramCandidates", there are three criteria that make a paramCandidate more desirable:
277 | // It occurs near the beginning of paramCandidates ("p" will pick the closest satisfactory parameter to the start)
278 | // the types match
279 | // the param's context works
280 |
281 | for(int i = 0; i < paramCandidates.size(); i++) {
282 | ParamCandidate candidate = paramCandidates.get(i);
283 |
284 | // Do the types match?
285 | if("%d".equals(p.param) && isDouble(candidate.token) && p.satisfiesContext(tokens, candidate.position, numAllowableErrors)) {
286 | args[index] = new VoiceArgument<>(Double.class, Double.parseDouble(candidate.token));
287 | paramCandidates.remove(i);
288 | break;
289 | } else if("%i".equals(p.param) && isInteger(candidate.token) && p.satisfiesContext(tokens, candidate.position, numAllowableErrors)) {
290 | args[index] = new VoiceArgument<>(Integer.class, convertInteger(candidate.token));
291 | paramCandidates.remove(i);
292 | break;
293 | } else if("%s".equals(p.param) && p.satisfiesContext(tokens, candidate.position, numAllowableErrors)) {
294 | StringBuilder sb = new StringBuilder(candidate.token);
295 |
296 | paramCandidates.remove(i);
297 |
298 | for(int tmp = i; tmp < paramCandidates.size(); tmp++) {
299 | ParamCandidate c = paramCandidates.get(tmp);
300 |
301 | if(p.satisfiesContext(tokens, c.position, 0) && tmp < paramCandidates.size() - (command.params.size() - index - 1)) {
302 | sb.append(" ").append(c.token);
303 | paramCandidates.remove(tmp);
304 | tmp--;
305 | } else {
306 | break;
307 | }
308 | }
309 |
310 | args[index] = new VoiceArgument<>(String.class, sb.toString());
311 |
312 | break;
313 | }
314 | }
315 |
316 | index++;
317 | }
318 |
319 | // step 5, apply the parameter assignments to the command phrase and create a vector with it
320 | String resolvedPhrase = command.phrase;
321 |
322 | index = 0;
323 | for(Param p : command.params) {
324 | if(args[index] != null) {
325 | resolvedPhrase = resolvedPhrase.replaceFirst(p.param, args[index].toString());
326 | } else {
327 | return null;
328 | }
329 |
330 | index++;
331 | }
332 |
333 | this.resolvedVector = new PhraseVector(resolvedPhrase);
334 | return args;
335 | }
336 |
337 | public double score(PhraseVector documentVector) {
338 | this.score = new PhraseVector(transcript).cosine(documentVector.words, resolvedVector);
339 | return this.score;
340 | }
341 | }
342 |
343 | // stores the words in the voice command that occur before and after the voice command
344 | private static class Param {
345 | private final ArrayList wordsBefore = new ArrayList<>(), wordsAfter = new ArrayList<>();
346 | private final String param;
347 |
348 | Param(ArrayList words, int index) {
349 | for(int i = 0; i < index; i++) {
350 | if(words.get(i).equals("%s") || words.get(i).equals("%i") || words.get(i).equals("%d")) continue;
351 | wordsBefore.add(words.get(i));
352 | }
353 | param = words.get(index);
354 |
355 | for(int i = index + 1; i < words.size(); i++) {
356 | if(words.get(i).equals("%s") || words.get(i).equals("%i") || words.get(i).equals("%d")) continue;
357 | wordsAfter.add(words.get(i));
358 | }
359 | }
360 |
361 | public boolean satisfiesContext(ArrayList context, int consideredToken, int numErrorsAllowed) {
362 | int errors = 0;
363 |
364 | // Create temporary sets for the words
365 | ArrayList before = new ArrayList<>();
366 | ArrayList after = new ArrayList<>();
367 |
368 | for(int i = 0; i < consideredToken; i++) {
369 | before.add(context.get(i));
370 | }
371 |
372 | for(int i = consideredToken + 1; i < context.size(); i++) {
373 | after.add(context.get(i));
374 | }
375 |
376 | for(String req : wordsBefore) {
377 | if(before.contains(req)) {
378 | before.remove(req);
379 | } else {
380 | errors++;
381 | }
382 | }
383 |
384 | for(String req : wordsAfter) {
385 | if(after.contains(req)) {
386 | after.remove(req);
387 | } else {
388 | errors++;
389 | }
390 | }
391 |
392 | return errors <= numErrorsAllowed;
393 | }
394 | }
395 |
396 | private static class PhraseVector {
397 | private final HashMap words;
398 |
399 | PhraseVector(String phrase) {
400 | words = new HashMap<>();
401 |
402 | ArrayList tokens = tokenize(phrase);
403 | for(String word : tokens) {
404 | int count = words.getOrDefault(word, 0);
405 | words.put(word, count + 1);
406 | }
407 | }
408 |
409 | private PhraseVector(HashMap words) {
410 | this.words = words;
411 | }
412 |
413 | private static ArrayList tokenize(String phrase) {
414 | phrase = phrase.replaceAll("%s", "").replaceAll("%i", "").replaceAll("%d", "")
415 | .replaceAll("[^a-zA-z0-9 -]", "").trim().toLowerCase();
416 |
417 | ArrayList tokens = new ArrayList<>(Arrays.asList(phrase.split("\\s+")));
418 |
419 | tokens.removeIf(STOPS::contains);
420 |
421 | return tokens;
422 | }
423 |
424 | // Does not preserve frequencies
425 | PhraseVector merge(PhraseVector vec) {
426 | HashMap merged = new HashMap<>();
427 | merged.putAll(words);
428 | merged.putAll(vec.words);
429 | return new PhraseVector(merged);
430 | }
431 |
432 | int[] asVector(HashMap termMatrix) {
433 | int[] vector = new int[termMatrix.size()];
434 |
435 | int index = 0;
436 | for(String key : termMatrix.keySet()) {
437 | vector[index] = words.getOrDefault(key, 0);
438 | index++;
439 | }
440 |
441 | return vector;
442 | }
443 |
444 | double cosine(HashMap termMatrix, PhraseVector vec) {
445 | int[] vec1 = asVector(termMatrix);
446 | int[] vec2 = vec.asVector(termMatrix);
447 |
448 | if(vec1.length != vec2.length) {
449 | throw new RuntimeException("Vector lengths must be the same.");
450 | }
451 |
452 | int innerProduct = 0;
453 | double vec1Length = 0;
454 | double vec2Length = 0;
455 |
456 | for(int i = 0; i < vec1.length; i++) {
457 | innerProduct += (vec1[i] * vec2[i]);
458 |
459 | vec1Length += (vec1[i] * vec1[i]);
460 | vec2Length += (vec2[i] * vec2[i]);
461 | }
462 |
463 | return (double) innerProduct / (Math.sqrt(vec1Length) * Math.sqrt(vec2Length));
464 | }
465 | }
466 |
467 | // common english words to remove
468 | private static final HashSet STOPS = new HashSet<>();
469 |
470 | static {
471 | final String[] STOP_WORDS =
472 | {"i", "me", "my", "myself", "we", "our", "ours", "ourselves", "you", "your", "yours", "yourself", "yourselves", "he", "him", "his", "himself", "she", "her", "hers", "herself",
473 | "it", "its", "itself", "they", "them", "their", "theirs", "themselves", "what", "which", "who", "whom", "this", "that", "these", "those", "am", "is", "are", "was", "were",
474 | "be", "been", "being", "have", "has", "had", "having", "do", "does", "did", "doing", "a", "an", "the", "and", "but", "if", "or", "because", "as", "until", "while", "of", "at",
475 | "by", "for", "with", "about", "against", "between", "into", "through", "during", "before", "after", "above", "below", "to", "from", "up", "down", "in", "out", "on", "off", "over",
476 | "under", "again", "further", "then", "once", "here", "there", "when", "where", "why", "how", "all", "any", "both", "each", "few", "more", "most", "other", "some", "such", "no",
477 | "nor", "not", "only", "own", "same", "so", "than", "too", "very", "s", "t", "can", "will", "just", "don", "should", "now"};
478 |
479 | STOPS.addAll(Arrays.asList(STOP_WORDS));
480 | }
481 |
482 | private static final Pattern R_INTEGER = Pattern.compile("^[-+]?\\d+$");
483 | private static final Pattern R_DOUBLE = Pattern.compile("\\d+\\.?\\d*");
484 |
485 | private static final ArrayList WORDS = new ArrayList<>();
486 |
487 | static {
488 | Collections.addAll(WORDS, "one", "two", "three", "four", "five", "six", "seven", "eight", "nine");
489 | }
490 |
491 | private static int convertInteger(String s) {
492 | if(WORDS.contains(s)) {
493 | return WORDS.indexOf(s) + 1;
494 | } else {
495 | return Integer.parseInt(s);
496 | }
497 | }
498 |
499 | private static boolean isInteger(String s) {
500 | return R_INTEGER.matcher(s).matches() || WORDS.contains(s);
501 | }
502 |
503 | private static boolean isDouble(String s) {
504 | return R_DOUBLE.matcher(s).matches();
505 | }
506 | }
507 |
--------------------------------------------------------------------------------