Skip to content

Commit

Permalink
Merge pull request #78 from JamesBrill/closest-fuzzy-match-wins-option
Browse files Browse the repository at this point in the history
bestMatchOnly option for fuzzy matching
  • Loading branch information
JamesBrill authored Dec 16, 2020
2 parents 88a7f82 + a075023 commit a7c6d2a
Show file tree
Hide file tree
Showing 6 changed files with 152 additions and 26 deletions.
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,12 @@ To respond when the user says a particular phrase, you can pass in a list of com
- `resetTranscript`: A function that sets the transcript to an empty string
- `matchInterim`: Boolean that determines whether "interim" results should be matched against the command. This will make your component respond faster to commands, but also makes false positives more likely - i.e. the command may be detected when it is not spoken. This is `false` by default and should only be set for simple commands.
- `isFuzzyMatch`: Boolean that determines whether the comparison between speech and `command` is based on similarity rather than an exact match. Fuzzy matching is useful for commands that are easy to mispronounce or be misinterpreted by the Speech Recognition engine (e.g. names of places, sports teams, restaurant menu items). It is intended for commands that are string literals without special characters. If `command` is a string with special characters or a `RegExp`, it will be converted to a string without special characters when fuzzy matching. The similarity that is needed to match the command can be configured with `fuzzyMatchingThreshold`. `isFuzzyMatch` is `false` by default. When it is set to `true`, it will pass four arguments to `callback`:
- The value of `command`
- The value of `command` (with any special characters removed)
- The speech that matched `command`
- The similarity between `command` and the speech
- The object mentioned in the `callback` description above
- `fuzzyMatchingThreshold`: If the similarity of speech to `command` is higher than this value when `isFuzzyMatch` is turned on, the `callback` will be invoked. You should set this only if `isFuzzyMatch` is `true`. It takes values between `0` (will match anything) and `1` (needs an exact match). The default value is `0.8`.
- `bestMatchOnly`: Boolean that, when `isFuzzyMatch` is `true`, determines whether the callback should only be triggered by the command phrase that _best_ matches the speech, rather than being triggered by all matching fuzzy command phrases. This is useful for fuzzy commands with multiple command phrases assigned to the same callback function - you may only want the callback to be triggered once for each spoken command. You should set this only if `isFuzzyMatch` is `true`. The default value is `false`.

### Command symbols

Expand Down Expand Up @@ -184,7 +185,7 @@ const Dictaphone = () => {
},
{
command: ['Hello', 'Hi'],
callback: () => setMessage('Hi there!'),
callback: ({ command }) => setMessage(`Hi there! You said: "${command}"`),
matchInterim: true
},
{
Expand All @@ -194,6 +195,13 @@ const Dictaphone = () => {
isFuzzyMatch: true,
fuzzyMatchingThreshold: 0.2
},
{
command: ['eat', 'sleep', 'leave'],
callback: (command) => setMessage(`Best matching command: ${command}`),
isFuzzyMatch: true,
fuzzyMatchingThreshold: 0.2,
bestMatchOnly: true
},
{
command: 'clear',
callback: ({ resetTranscript }) => resetTranscript()
Expand Down
7 changes: 7 additions & 0 deletions example/src/Dictaphone/DictaphoneWidgetA.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ const DictaphoneWidgetA = () => {
isFuzzyMatch: true,
fuzzyMatchingThreshold: 0.2
},
{
command: ['eat', 'sleep', 'leave'],
callback: (command) => setMessage(`Best matching command: ${command}`),
isFuzzyMatch: true,
fuzzyMatchingThreshold: 0.2,
bestMatchOnly: true
},
{
command: 'clear',
callback: ({ resetTranscript }) => resetTranscript(),
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-speech-recognition",
"version": "3.5.0",
"version": "3.6.0",
"description": "💬Speech recognition for your React app",
"main": "lib/index.js",
"scripts": {
Expand Down
82 changes: 60 additions & 22 deletions src/SpeechRecognition.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,33 +27,71 @@ const useSpeechRecognition = ({
clearTranscript()
}, [recognitionManager])

const testFuzzyMatch = (command, input, fuzzyMatchingThreshold) => {
const commandToString = (typeof command === 'object') ? command.toString() : command
const commandWithoutSpecials = commandToString
.replace(/[&/\\#,+()!$~%.'":*?<>{}]/g, '')
.replace(/ +/g, ' ')
.trim()
const howSimilar = compareTwoStringsUsingDiceCoefficient(commandWithoutSpecials, input)
if (howSimilar >= fuzzyMatchingThreshold) {
return {
command,
commandWithoutSpecials,
howSimilar,
isFuzzyMatch: true
}
}
return null
}

const testMatch = (command, input) => {
const pattern = commandToRegExp(command)
const result = pattern.exec(input)
if (result) {
return {
command,
parameters: result.slice(1)
}
}
return null
}

const matchCommands = useCallback(
(newInterimTranscript, newFinalTranscript) => {
commandsRef.current.forEach(({ command, callback, matchInterim = false, isFuzzyMatch = false, fuzzyMatchingThreshold = 0.8 }) => {
commandsRef.current.forEach(({
command,
callback,
matchInterim = false,
isFuzzyMatch = false,
fuzzyMatchingThreshold = 0.8,
bestMatchOnly = false
}) => {
const input = !newFinalTranscript && matchInterim
? newInterimTranscript.trim()
: newFinalTranscript.trim()
const subcommands = Array.isArray(command) ? command : [command]
subcommands.forEach(subcommand => {
const input = !newFinalTranscript && matchInterim
? newInterimTranscript.trim()
: newFinalTranscript.trim()
const results = subcommands.map(subcommand => {
if (isFuzzyMatch) {
const commandToString = (typeof subcommand === 'object') ? subcommand.toString() : subcommand
const commandWithoutSpecials = commandToString
.replace(/[&/\\#,+()!$~%.'":*?<>{}]/g, '')
.replace(/ +/g, ' ')
.trim()
const howSimilar = compareTwoStringsUsingDiceCoefficient(commandWithoutSpecials, input)
if (howSimilar >= fuzzyMatchingThreshold) {
callback(commandWithoutSpecials, input, howSimilar, { command: subcommand, resetTranscript })
}
} else {
const pattern = commandToRegExp(subcommand)
const result = pattern.exec(input)
if (result) {
const parameters = result.slice(1)
callback(...parameters, { command: subcommand, resetTranscript })
}
return testFuzzyMatch(subcommand, input, fuzzyMatchingThreshold)
}
})
return testMatch(subcommand, input)
}).filter(x => x)
if (isFuzzyMatch && bestMatchOnly && results.length >= 2) {
results.sort((a, b) => b.howSimilar - a.howSimilar)
const { command, commandWithoutSpecials, howSimilar } = results[0]
callback(commandWithoutSpecials, input, howSimilar, { command, resetTranscript })
} else {
results.forEach(result => {
if (result.isFuzzyMatch) {
const { command, commandWithoutSpecials, howSimilar } = result
callback(commandWithoutSpecials, input, howSimilar, { command, resetTranscript })
} else {
const { command, parameters } = result
callback(...parameters, { command, resetTranscript })
}
})
}
})
}, [resetTranscript]
)
Expand Down
73 changes: 73 additions & 0 deletions src/SpeechRecognition.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,79 @@ describe('SpeechRecognition', () => {
expect(mockCommandCallback2.mock.calls.length).toBe(1)
})

test('fuzzy callback called for each matching command in array by default', async () => {
mockRecognitionManager()
const mockCommandCallback = jest.fn()
const command1 = 'I want to eat'
const command2 = 'I want to sleep'
const commands = [
{
command: [command1, command2],
callback: mockCommandCallback,
isFuzzyMatch: true,
fuzzyMatchingThreshold: 0.2
}
]
const { result } = renderHook(() => useSpeechRecognition({ commands }))
const { resetTranscript } = result.current
const speech = 'I want to leap'

await act(async () => {
await SpeechRecognition.startListening()
})
act(() => {
SpeechRecognition.getRecognition().say(speech)
})

expect(mockCommandCallback.mock.calls.length).toBe(2)
expect(mockCommandCallback).nthCalledWith(1,
command1,
'I want to leap',
0.7368421052631579,
{ command: command1, resetTranscript }
)
expect(mockCommandCallback).nthCalledWith(2,
command2,
'I want to leap',
0.6666666666666666,
{ command: command2, resetTranscript }
)
})

test('fuzzy callback called only for best matching command in array when bestMatchOnly is true', async () => {
mockRecognitionManager()
const mockCommandCallback = jest.fn()
const command1 = 'I want to eat'
const command2 = 'I want to sleep'
const commands = [
{
command: [command1, command2],
callback: mockCommandCallback,
isFuzzyMatch: true,
fuzzyMatchingThreshold: 0.2,
bestMatchOnly: true
}
]
const { result } = renderHook(() => useSpeechRecognition({ commands }))
const { resetTranscript } = result.current
const speech = 'I want to leap'

await act(async () => {
await SpeechRecognition.startListening()
})
act(() => {
SpeechRecognition.getRecognition().say(speech)
})

expect(mockCommandCallback.mock.calls.length).toBe(1)
expect(mockCommandCallback).nthCalledWith(1,
command1,
'I want to leap',
0.7368421052631579,
{ command: command1, resetTranscript }
)
})

test('when command is regex with fuzzy match true runs similarity check with regex converted to string', async () => {
mockRecognitionManager()
const mockCommandCallback = jest.fn()
Expand Down

0 comments on commit a7c6d2a

Please sign in to comment.