src/administrative-sdk/pronunciation-analysis/pronunciation-analysis-controller.js
/* eslint-disable
camelcase
*/
import Base64Utils from '../utils/base64-utils';
import Connection from '../connection/connection-controller';
import Phoneme from '../phoneme/phoneme';
import PronunciationAnalysis from './pronunciation-analysis';
import PronunciationChallenge from '../pronunciation-challenge/pronunciation-challenge';
import Word from '../word/word';
import WordChunk from '../word-chunk/word-chunk';
import when from 'when';
/**
* Controller class for the PronunciationAnalysis model.
* @private
*/
export default class PronunciationAnalysisController {
/**
* @param {Connection} connection - Object to use for making a connection to the REST API and Websocket server.
*/
constructor(connection) {
/**
* Object to use for making a connection to the REST API and Websocket server.
* @type {Connection}
*/
this._connection = connection;
}
/**
* Create a `its.Word` domain model from JSON data.
*
* @param {object[]} inWords - The words array from the PronunciationAnalysis API.
* @returns {Word[]} An array of {@link Word} domain models.
*/
static _wordsToModels(inWords) {
return inWords.map(word => {
const chunks = word.chunks.map(chunk => {
const phonemes = (chunk.phonemes || []).map(phoneme => {
const newPhoneme = new Phoneme(
phoneme.ipa, phoneme.score, phoneme.confidenceScore,
phoneme.verdict);
// Copy all properties as API docs indicate there may be a
// variable amount of phoneme properties.
return Object.assign(newPhoneme, phoneme);
});
return new WordChunk(chunk.graphemes, chunk.score, chunk.verdict, phonemes);
});
return new Word(chunks);
});
}
/**
* Initialise the pronunciation analysis challenge through RPCs.
*
* @param {PronunciationChallenge} challenge - Challenge.
* @private
*/
pronunciationAnalysisInitChallenge(challenge) {
return this._connection._session.call('nl.itslanguage.pronunciation.init_challenge',
[this._connection._analysisId, challenge.id])
.then(analysisId => {
console.log('Challenge initialised for analysisId: ' + this._connection._analysisId);
return analysisId;
})
.then(() => this._connection._session.call('nl.itslanguage.pronunciation.alignment',
[this._connection._analysisId]))
.then(alignment => {
this._referenceAlignment = alignment;
console.log('Reference alignment retrieved', alignment);
});
}
/**
* Initialise the pronunciation analysis audio specs through RPCs.
*
* @param {AudioRecorder} recorder - AudioRecorder.
* @param {Function} dataavailableCb - Callback.
* @private
*/
pronunciationAnalysisInitAudio(recorder, dataavailableCb) {
// Indicate to the socket server that we're about to start recording a
// challenge. This allows the socket server some time to fetch the metadata
// and reference audio to start the analysis when audio is actually submitted.
const specs = recorder.getAudioSpecs();
return this._connection.call('pronunciation.init_audio',
[this._connection._analysisId, specs.audioFormat], specs.audioParameters)
.then(analysisId => {
console.log('Accepted audio parameters for analysisId after init_audio: ' + this._connection._analysisId);
// Start listening for streaming data.
recorder.addEventListener('dataavailable', dataavailableCb);
return analysisId;
});
}
/**
* Start a pronunciation analysis from streaming audio.
*
* @param {PronunciationChallenge} challenge - The pronunciation challenge to perform.
* @param {AudioRecorder} recorder - The audio recorder to extract audio from.
* @param {?boolean} trim - Whether to trim the start and end of recorded audio (default: true).
* @returns {Promise.<PronunciationAnalysis>} A {@link https://github.com/cujojs/when} Promise containing a {@link PronunciationAnalysis}.
* @emits {string} 'ReadyToReceive' when the call is made to receive audio. The recorder can now send audio.
* @emits {Object} When the sent audio has finished alignment. Aligning audio is the process of mapping the audio
* to spoken words and determining when what is said. An object is sent containing a property 'progress',
* which is the sent audio alignment, and a property 'referenceAlignment' which is the alignment of the
* reference audio.
* @throws {Promise.<Error>} challenge parameter of type "PronunciationChallenge" is required.
* @throws {Promise.<Error>} challenge.id field of type "string" is required.
* @throws {Promise.<Error>} If the connection is not open.
* @throws {Promise.<Error>} If the recorder is already recording.
* @throws {Promise.<Error>} If a session is already in progress.
* @throws {Promise.<Error>} If something went wrong during analysis.
*/
startStreamingPronunciationAnalysis(challenge, recorder, trim) {
if (!(challenge instanceof PronunciationChallenge)) {
return Promise.reject(new Error('challenge parameter of type "PronunciationChallenge" is required'));
}
if (typeof challenge.id !== 'string') {
return Promise.reject(new Error('challenge.id field of type "string" is required'));
}
if (!this._connection._session) {
return Promise.reject(new Error('WebSocket connection was not open.'));
}
if (recorder.isRecording()) {
return Promise.reject(new Error('Recorder should not yet be recording.'));
}
if (this._connection._analysisId !== null) {
return Promise.reject(new Error('Session with analysisId ' + this._connection._analysisId +
' still in progress.'));
}
const self = this;
this._connection._analyisId = null;
let trimAudioStart = 0.15;
const trimAudioEnd = 0.0;
if (trim === false) {
trimAudioStart = 0.0;
}
return new when.Promise((resolve, reject, notify) => {
function reportDone(data) {
const analysis = new PronunciationAnalysis(
challenge.id, data.userId, data.id,
new Date(data.created), new Date(data.updated),
self._connection.addAccessToken(data.audioUrl),
data.score, data.confidenceScore,
PronunciationAnalysisController._wordsToModels(data.words)
);
resolve({analysisId: self._connection._analysisId, analysis});
}
function reportProgress(progress) {
notify({progress, referenceAlignment: self._referenceAlignment});
}
function reportError(data) {
// Either there was an unexpected error, or the audio failed to
// align, in which case no analysis is provided, but just the
// basic metadata.
const {message} = data;
if (data.id) {
const analysis = new PronunciationAnalysis(
challenge.id, data.userId, data.id,
new Date(data.created), new Date(data.updated),
self._connection.addAccessToken(data.audioUrl));
reject({analysis, message});
} else {
reject({message});
}
}
// Start streaming the binary audio when the user instructs
// the audio recorder to start recording.
function startStreaming(chunk) {
const encoded = Base64Utils._arrayBufferToBase64(chunk);
console.log('Sending audio chunk to websocket for analysisId: ' +
self._connection._analysisId);
self._connection.call('pronunciation.write',
[self._connection._analysisId, encoded, 'base64'])
.catch(res => {
Connection.logRPCError(res);
reportError(res);
});
}
function initAnalysis(analysisId) {
self._connection._analysisId = analysisId;
console.log('Got analysisId after initialisation: ' + self._connection._analysisId);
}
// Stop listening when the audio recorder stopped.
function stopListening() {
recorder.removeEventListener('recorded', stopListening);
recorder.removeEventListener('dataavailable', startStreaming);
// When done, submit any plain text (non-JSON) to start analysing.
self._connection.call('pronunciation.analyse',
[self._connection._analysisId], {}, {receive_progress: true})
.progress(progress => {
reportProgress(progress);
})
.then(reportDone)
.catch(res => {
const {error, kwargs: {analysis = {}}} = res;
if (error === 'nl.itslanguage.ref_alignment_failed') {
analysis.message = 'Reference alignment failed';
} else if (error === 'nl.itslanguage.alignment_failed') {
analysis.message = 'Alignment failed';
} else if (error === 'nl.itslanguage.analysis_failed') {
analysis.message = 'Analysis failed';
} else {
analysis.message = 'Unhandled error';
Connection.logRPCError(res);
}
reportError(analysis);
});
}
recorder.addEventListener('recorded', stopListening);
self._connection.call('pronunciation.init_analysis', [],
{
trimStart: trimAudioStart,
trimEnd: trimAudioEnd
})
.then(initAnalysis)
.then(() => self.pronunciationAnalysisInitChallenge(challenge))
.then(() => notify('ReadyToReceive'))
.then(() => new Promise(resolve_ => {
if (recorder.hasUserMediaApproval()) {
resolve_();
} else {
recorder.addEventListener('ready', resolve_);
}
}))
.then(() => self.pronunciationAnalysisInitAudio(recorder, startStreaming))
.catch(reject);
})
.then(res => {
self._connection._analysisId = null;
return Promise.resolve(res);
})
.catch(error => {
self._connection._analysisId = null;
Connection.logRPCError(error);
return Promise.reject(error);
});
}
/**
* Get a pronunciation analysis in a pronunciation challenge from the current active {@link Organisation} derived
* from the OAuth2 scope.
*
* @param {string} challengeId - Specify a pronunciation challenge identifier.
* @param {string} analysisId - Specify a pronunciation analysis identifier.
* @returns {Promise.<PronunciationAnalysis>} Promise containing a PronunciationAnalysis.
* @throws {Promise.<Error>} {@link PronunciationChallenge#id} field is required.
* @throws {Promise.<Error>} {@link PronunciationAnalysis#id} field is required.
* @throws {Promise.<Error>} If no result could not be found.
*/
getPronunciationAnalysis(challengeId, analysisId) {
if (!challengeId) {
return Promise.reject(new Error('challengeId field is required'));
}
if (!analysisId) {
return Promise.reject(new Error('analysisId field is required'));
}
const url = this._connection._settings.apiUrl + '/challenges/pronunciation/' +
challengeId + '/analyses/' + analysisId;
return this._connection._secureAjaxGet(url)
.then(datum => {
const analysis = new PronunciationAnalysis(challengeId, datum.userId,
datum.id, new Date(datum.created), new Date(datum.updated),
datum.audioUrl, datum.score, datum.confidenceScore, null);
// Alignment may not be successful, in which case the analysis
// is not available, but it's still an attempt that is available,
// albeit without extended attributes like score and phonemes.
if (datum.words) {
analysis.words = PronunciationAnalysisController._wordsToModels(datum.words);
}
return analysis;
});
}
/**
* Get and return all pronunciation analyses in a specific pronunciation challenge from the current active
* {@link Organisation} derived from the OAuth2 scope.
*
* @param {string} challengeId - Specify a pronunciation challenge identifier to list
* speech recordings for.
* @param {boolean} [detailed=false] - Returns extra analysis metadata when true.
* @returns {Promise.<PronunciationAnalysis[]>} Promise containing an array PronunciationAnalyses.
* @throws {Promise.<Error>} {@link PronunciationChallenge#id} field is required.
* @throws {Promise.<Error>} If no result could not be found.
*/
getPronunciationAnalyses(challengeId, detailed) {
if (!challengeId) {
return Promise.reject(new Error('challengeId field is required'));
}
let url = this._connection._settings.apiUrl + '/challenges/pronunciation/' +
challengeId + '/analyses';
if (detailed) {
url += '?detailed=true';
}
return this._connection._secureAjaxGet(url)
.then(data => data.map(datum => {
const analysis = new PronunciationAnalysis(challengeId, datum.userId,
datum.id, new Date(datum.created), new Date(datum.updated),
datum.audioUrl, datum.score, datum.confidenceScore, null);
// Alignment may not be successful, in which case the analysis
// is not available, but it's still an attempt that is available,
// albeit without extended attributes like score and phonemes.
if (datum.words) {
analysis.words = PronunciationAnalysisController._wordsToModels(datum.words);
}
return analysis;
}));
}
}