Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions .github/check-lifecycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
'use strict'

// CI-only regression checks. No microphone, credentials, native addon or timers.
const assert = require('assert')
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const { EventEmitter } = require('events')
const { PassThrough, Writable, Duplex } = require('stream')

function fixture(options = {}) {
const mics = [], children = [], requests = [], channels = [], errors = []
const timers = new Set()
let starts = 0, stops = 0, synchronousError
class Models {
add(model) { this.model = model }
lookup(index) { if (index !== 1) throw new Error('bad index'); return 'hello' }
}
class Detector extends Writable {
constructor(opts) { super(); this.opts = opts }
_write(chunk, encoding, callback) { callback() }
}
const record = {
start() {
starts++
const mic = new PassThrough()
const child = new EventEmitter()
child.exitCode = null
child.signalCode = null
mics.push(mic); children.push(child)
return mic
},
stop() { stops++; return children[children.length - 1] }
}
const recognizer = {
streamingRecognize(request) {
if (synchronousError) throw synchronousError
requests.push(request)
const channel = new Duplex({ objectMode: true, read() {}, write(data, enc, cb) { cb() } })
const end = channel.end.bind(channel)
channel.endCalls = 0
channel.end = () => { channel.endCalls++; return end() }
channels.push(channel)
return channel
}
}
const mod = { exports: {} }
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../index.js'), 'utf8'), {
module: mod, __dirname: path.join(__dirname, '..'),
require(name) {
if (name === 'node-record-lpcm16') return record
if (name === 'snowboy') return { Models, Detector }
if (name === './lib/annyang-core.js') return { trigger() {} }
return require(name)
},
setTimeout(fn) { const timer = { fn, unref() {} }; timers.add(timer); return timer },
clearTimeout(timer) { timers.delete(timer) }
})
const Sonus = mod.exports
const sonus = Sonus.init(options, recognizer)
sonus.on('error', error => errors.push(error))
const tick = () => new Promise(resolve => setImmediate(resolve))
return { Sonus, sonus, mics, children, requests, channels, errors, timers, recognizer,
starts: () => starts, stops: () => stops, tick,
syncError(error) { synchronousError = error },
close(index = children.length - 1) { children[index].exitCode = 0; children[index].emit('close', 0) }
}
}

const checks = []
function check(name, fn) { checks.push([name, fn]) }

check('legacy no-argument stop resets state and ends cloud stream', async () => {
const f = fixture({ hotwords: -1 }); f.Sonus.start(f.sonus)
f.Sonus.stop()
assert.strictEqual(f.sonus.started, false)
assert.strictEqual(f.sonus.csr.listening, false)
assert.strictEqual(f.channels[0].endCalls, 1)
f.close(); f.Sonus.stop()
assert.strictEqual(f.stops(), 1)
})
check('inactive instance cannot stop another recorder; start is exclusive and idempotent', () => {
const f = fixture(); const other = f.Sonus.init({}, {})
f.Sonus.start(f.sonus); f.Sonus.start(f.sonus)
assert.strictEqual(f.starts(), 1)
f.Sonus.stop(other); assert.strictEqual(f.stops(), 0)
assert.throws(() => f.Sonus.start(other), /already in use/)
f.Sonus.stop(); assert.throws(() => f.Sonus.start(f.sonus), /still stopping/)
f.close(); f.Sonus.start(other); assert.strictEqual(f.starts(), 2)
f.Sonus.stop(); f.close()
})
check('conversation defaults, repeated phrases and empty event responses', () => {
const f = fixture({ hotwords: -1 }); f.Sonus.start(f.sonus)
assert.strictEqual(f.requests[0].config.languageCode, 'en-US')
f.channels[0].emit('data', { speechEventType: 'END_OF_SINGLE_UTTERANCE' })
assert.strictEqual(f.sonus.csr.listening, true)
let final
f.sonus.on('final-result', text => { final = text; f.Sonus.start(f.sonus) })
f.channels[0].emit('data', { results: [{ isFinal: true, alternatives: [{ transcript: 'hello' }] }] })
assert.strictEqual(final, 'hello')
assert.strictEqual(f.starts(), 1)
assert.strictEqual(f.channels.length, 2)
f.channels[0].emit('error', new Error('late error'))
assert.strictEqual(f.errors.length, 0)
assert.strictEqual(f.sonus.csr.listening, true)
f.Sonus.stop(); f.close()
})
check('cloud setup and response errors retain their cause and allow retry', () => {
const f = fixture(); f.Sonus.start(f.sonus)
const cause = new Error('credentials')
f.syncError(cause); f.Sonus.trigger(f.sonus, 1)
assert.strictEqual(f.errors[0].streamingError, cause)
assert.strictEqual(f.sonus.csr.listening, false)
f.syncError(null); f.Sonus.trigger(f.sonus, 1)
f.channels[0].emit('data', { error: cause })
assert.strictEqual(f.channels[0].endCalls, 1)
assert.strictEqual(f.errors[1].streamingError, cause)
assert.throws(() => f.Sonus.trigger(f.sonus, 2), error => error === 'INVALID_INDEX')
f.Sonus.stop(); f.close()
})
check('cloud EOF resets listening and emits empty final once', () => {
const f = fixture({ hotwords: -1 }); let finals = 0
f.sonus.on('final-result', () => finals++)
f.Sonus.start(f.sonus); f.channels[0].emit('end'); f.channels[0].emit('close')
assert.strictEqual(finals, 1); assert.strictEqual(f.sonus.csr.listening, false)
f.Sonus.trigger(f.sonus); assert.strictEqual(f.channels.length, 2)
f.Sonus.stop(); f.close()
})
check('arecord rollover reconnects conversation and resets byte counts', async () => {
const f = fixture({ hotwords: -1, recordProgram: 'arecord' }); f.Sonus.start(f.sonus)
f.sonus.byteCount = 1500000000
f.mics[0].write(Buffer.alloc(2)); await f.tick()
assert.strictEqual(f.stops(), 1); assert.strictEqual(f.starts(), 1)
assert.strictEqual(f.channels[0].endCalls, 1)
f.close(0)
assert.strictEqual(f.starts(), 2); assert.strictEqual(f.channels.length, 2)
assert.strictEqual(f.sonus.byteCount, 0)
f.Sonus.stop(); f.close()
})
check('stop during rollover never resurrects recording', async () => {
const f = fixture({ recordProgram: 'arecord' }); f.Sonus.start(f.sonus)
f.sonus.byteCount = 1500000000; f.mics[0].write(Buffer.alloc(2)); await f.tick()
f.Sonus.stop(); f.close()
assert.strictEqual(f.starts(), 1); assert.strictEqual(f.sonus.started, false)
assert.strictEqual(f.timers.size, 0)
})
check('rollover timeout is reported and does not restart later', async () => {
const f = fixture({ recordProgram: 'arecord' }); f.Sonus.start(f.sonus)
f.sonus.byteCount = 1500000000; f.mics[0].write(Buffer.alloc(2)); await f.tick()
for (const timer of [...f.timers]) timer.fn()
assert.match(f.errors[0].recordingError.message, /did not stop/)
assert.strictEqual(f.sonus.started, false)
f.close(); assert.strictEqual(f.starts(), 1)
})
check('pause and resume work without nonexistent recorder pause methods', () => {
const f = fixture({ hotwords: -1 }); f.Sonus.start(f.sonus)
f.sonus.pause(); assert.strictEqual(f.mics[0].isPaused(), true)
assert.strictEqual(f.sonus.csr.listening, false)
f.sonus.resume(); assert.strictEqual(f.sonus.csr.listening, true)
assert.strictEqual(f.starts(), 1); assert.strictEqual(f.channels.length, 2)
f.Sonus.stop(); f.close()
})
check('recorder EOF and detector errors clean up rather than disappear', async () => {
const f = fixture(); f.Sonus.start(f.sonus)
f.mics[0].end(); await f.tick()
assert.strictEqual(f.sonus.started, false)
assert.match(f.errors[0].recordingError.message, /ended unexpectedly/)
assert.strictEqual(f.sonus.detector.writableEnded, false)
f.close()
f.Sonus.start(f.sonus)
const cause = new Error('detection')
f.sonus.detector.emit('error', cause)
assert.strictEqual(f.errors[1].detectionError, cause)
assert.strictEqual(f.sonus.started, false)
f.close()
})

;(async () => {
for (const [name, run] of checks) { await run(); console.log('PASS:', name) }
console.log(`${checks.length} lifecycle regression checks passed`)
})().catch(error => { console.error(error); process.exitCode = 1 })
74 changes: 74 additions & 0 deletions .github/workflows/package-integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Standalone Snowboy integration
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
integration:
runs-on: ubuntu-22.04
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node: ['14.21.3', '20', '22']
env:
npm_config_python: /usr/bin/python3
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: evancohen/snowboy
ref: 45c0054275c3c41a8e531ab28686f959ef8195b2
path: candidate-snowboy
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
package-manager-cache: false
- name: Install native prerequisites
run: |
sudo apt-get update
sudo apt-get install -y build-essential python3 libatlas-base-dev
- name: Check recording and recognition lifecycle regressions
run: node .github/check-lifecycle.js
# Test the unpublished candidate without changing Sonus's dependency range.
- name: Pack Snowboy candidate
working-directory: candidate-snowboy
run: |
npm install --ignore-scripts --no-audit --no-fund
npm run build
npm pack --ignore-scripts
- name: Install both packages as a consumer
env:
SONUS_COMMIT: ${{ github.sha }}
run: |
mkdir "$RUNNER_TEMP/sonus-consumer"
cd "$RUNNER_TEMP/sonus-consumer"
npm init -y
npm install --no-audit --no-fund "$GITHUB_WORKSPACE/candidate-snowboy/snowboy-1.4.0-rc.0.tgz"
npm install --no-audit --no-fund "git+https://github.com/evancohen/sonus.git#$SONUS_COMMIT"
node <<'NODE'
'use strict'

const assert = require('assert')
const path = require('path')
const root = path.dirname(require.resolve('sonus/package.json'))
const Sonus = require(root)
const sonus = Sonus.init({ applyFrontend: false }, {})
assert.strictEqual(sonus.detector.sampleRate(), 16000)
assert.strictEqual(sonus.detector.numHotwords(), 1)
const result = sonus.detector.runDetection(Buffer.alloc(3200))
assert.ok(result === -2 || result === 0)
sonus.detector.destroy()
sonus.csr.destroy()
sonus.destroy()
const conversation = Sonus.init({ hotwords: -1 }, {})
assert.strictEqual(conversation.detector, undefined)
conversation.csr.destroy()
conversation.destroy()
console.log('Sonus with standalone Snowboy integration passed')
NODE
4 changes: 4 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.github/
candidate-snowboy/
node_modules/
*.tgz
32 changes: 29 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const language = "en-US"
### Initialize Sonus
Sonus's initialization accepts two paramaters:
**`options`** - an options object that contains your hotwords, language, etc
- **`hotwords`** - an array of recognizable hotwords
- **`hotwords`** - a non-empty array of recognizable hotwords, or `-1` for conversational mode without a hotword detector
- `language` - streaming language recognition
- `speechContexts` - Array of strings containing words/phrases so that the speech recognizer is more likely to recognize them.
- `recordProgram` - (default `'rec'`) Supports:
Expand All @@ -65,7 +65,7 @@ Sonus.start(sonus)

### Pause recognition
Pass your initialized sonus object into `Sonus.pause`.
Pausing recognition while streaming will not cancel the request, instead it will cause it to simulate the "end" of speech and return final results.
Pausing stops microphone consumption and ends the active cloud request. Its pending transcript is discarded.
**Example:**
``` javascript
Sonus.pause(sonus)
Expand All @@ -84,7 +84,7 @@ If you want to stop recognition enterly you can use `Sonus.stop`
``` javascript
Sonus.stop(sonus)
```
Note that after recognition is stopped it can not be started again without creating an enterly new sonus instance.
The instance can be started again after the recorder child process has closed.

### Trigger keyword/hotword manually
You can manuall trigger a hotword by passing your initialized sonus object and an index into `Sonus.trigger`
Expand Down Expand Up @@ -117,3 +117,29 @@ hotword
partial-result
final-result
error

### Recording lifecycle and conversational mode

The recording dependency supports one microphone process at a time. Starting
an already-running instance does not spawn another process; starting a different
instance while the recorder is occupied throws an error. `Sonus.stop(sonus)`
clears recognition state and stops that instance. For compatibility with older
examples, `Sonus.stop()` stops the active instance. Calls on an inactive instance
cannot stop another instance's recording. Wait for the returned child process's
`close` event before starting again; starting while it is stopping throws.

`Sonus.pause(sonus)` pauses microphone consumption and ends the current cloud
request. `Sonus.resume(sonus)` resumes consumption. These also accept no argument
for the active instance, and are available as `sonus.pause()` / `sonus.resume()`.

For conversational recognition, use `Sonus.init({ hotwords: -1 }, client)`.
`Sonus.start(sonus)` records one cloud-recognized phrase. After `final-result`,
call `Sonus.start(sonus)` or `Sonus.trigger(sonus)` to request the next phrase
without restarting the microphone. Language defaults to `en-US` in both modes.
Stopping cancels an active request and any pending recorder restart.

Listen for `error` events. Cloud errors are exposed as `{ streamingError }`,
recorder errors as `{ recordingError }`, and native detector errors as
`{ detectionError }`. A recorder that does not exit within eight seconds reports
an error and will not restart automatically. Sonus only stops its own recorder
child; it does not search for or kill other processes by name.
Loading