diff --git a/amd/build/audiohelper.min.js b/amd/build/audiohelper.min.js new file mode 100644 index 0000000..bd3d4ab --- /dev/null +++ b/amd/build/audiohelper.min.js @@ -0,0 +1,3 @@ +define("qtype_aitext/audiohelper",["jquery","core/log","qtype_aitext/wavencoder"],(function($,log,wavencoder){return log.debug("qtype_aitext Audio Helper initialising"),{encoder:null,microphone:null,isRecording:!1,audioContext:null,processor:null,uniqueid:null,alreadyhadsound:!1,silencecount:0,silenceintervals:15,silencelevel:25,config:{bufferLen:4096,numChannels:2,mimeType:"audio/wav"},clone:function(){return $.extend(!0,{},this)},init:function(waveHeight,uniqueid,therecorder){this.waveHeight=waveHeight,this.uniqueid=uniqueid,this.therecorder=therecorder,this.prepare_html(),window.AudioContext=window.AudioContext||window.webkitAudioContext},onStop:function(){},onStream:function(){},onError:function(){},prepare_html:function(){this.canvas=$("."+this.uniqueid+"_waveform"),this.canvasCtx=this.canvas[0].getContext("2d")},start:function(){var that=this;this.audioContext=new AudioContext,this.audioContext.createJavaScriptNode?this.processor=this.audioContext.createJavaScriptNode(this.config.bufferLen,this.config.numChannels,this.config.numChannels):this.audioContext.createScriptProcessor?this.processor=this.audioContext.createScriptProcessor(this.config.bufferLen,this.config.numChannels,this.config.numChannels):log.debug("WebAudio API has no support on this browser."),this.processor.connect(this.audioContext.destination);"audioSession"in navigator&&(navigator.audioSession.type="play-and-record",console.log("AudioSession API is supported")),navigator.mediaDevices.getUserMedia({audio:!0,video:!1}).then((function(stream){that.onStream(stream),that.isRecording=!0,that.therecorder.update_audio("isRecording",!0),that.tracks=stream.getTracks();for(var i=0;itrack.stop())),this.onStop(this.encoder.finish())},getBuffers:function(event){for(var buffers=[],ch=0;ch<2;++ch)buffers[ch]=event.inputBuffer.getChannelData(ch);return buffers},detectSilence:function(){this.listener.getByteFrequencyData(this.volumeData);let sum=0;for(var vindex=0;vindex=this.silenceintervals&&this.therecorder.silence_detected()):volume>this.silencelevel&&(this.alreadyhadsound=!0,this.silencecount=0)},drawWave:function(){var width=2*this.canvas.width();this.listener.getByteTimeDomainData(this.analyserData),this.canvasCtx.fillStyle="white",this.canvasCtx.fillRect(0,0,width,2*this.waveHeight),this.canvasCtx.lineWidth=5,this.canvasCtx.strokeStyle="gray",this.canvasCtx.beginPath();for(var slicewaveWidth=width/this.bufferLength,x=0,i=0;i track.stop());\n this.onStop(this.encoder.finish());\n },\n\n getBuffers: function(event) {\n var buffers = [];\n for (var ch = 0; ch < 2; ++ch) {\n buffers[ch] = event.inputBuffer.getChannelData(ch);\n }\n return buffers;\n },\n\n detectSilence: function () {\n\n this.listener.getByteFrequencyData(this.volumeData);\n\n let sum = 0;\n for (var vindex =0; vindex =this.silenceintervals){\n this.therecorder.silence_detected();\n }\n //if we have a sound, reset silence count to zero, and flag that we have started\n }else if(volume > this.silencelevel){\n this.alreadyhadsound = true;\n this.silencecount=0;\n }\n },\n\n drawWave: function() {\n\n var width = this.canvas.width() * 2;\n this.listener.getByteTimeDomainData(this.analyserData);\n\n this.canvasCtx.fillStyle = 'white';\n this.canvasCtx.fillRect(0, 0, width, this.waveHeight*2);\n\n this.canvasCtx.lineWidth = 5;\n this.canvasCtx.strokeStyle = 'gray';\n this.canvasCtx.beginPath();\n\n var slicewaveWidth = width / this.bufferLength;\n var x = 0;\n\n for (var i = 0; i < this.bufferLength; i++) {\n\n var v = this.analyserData[i] / 128.0;\n var y = v * this.waveHeight;\n\n if (i === 0) {\n // this.canvasCtx.moveTo(x, y);\n } else {\n this.canvasCtx.lineTo(x, y);\n }\n\n x += slicewaveWidth;\n }\n\n this.canvasCtx.lineTo(width, this.waveHeight);\n this.canvasCtx.stroke();\n\n }\n }; //end of this declaration\n\n\n});"],"names":["define","$","log","wavencoder","debug","encoder","microphone","isRecording","audioContext","processor","uniqueid","alreadyhadsound","silencecount","silenceintervals","silencelevel","config","bufferLen","numChannels","mimeType","clone","extend","this","init","waveHeight","therecorder","prepare_html","window","AudioContext","webkitAudioContext","onStop","onStream","onError","canvas","canvasCtx","getContext","start","that","createJavaScriptNode","createScriptProcessor","connect","destination","navigator","audioSession","type","console","mediaDevices","getUserMedia","audio","video","then","stream","update_audio","tracks","getTracks","i","length","track","kind","settings","getSettings","noiseSuppression","echoCancellation","createMediaStreamSource","sampleRate","onaudioprocess","event","encode","getBuffers","listener","createAnalyser","fftSize","bufferLength","frequencyBinCount","analyserData","Uint8Array","volumeData","clearRect","width","interval","setInterval","drawWave","detectSilence","catch","stop","clearInterval","state","close","disconnect","forEach","finish","buffers","ch","inputBuffer","getChannelData","getByteFrequencyData","sum","vindex","volume","Math","sqrt","silence_detected","getByteTimeDomainData","fillStyle","fillRect","lineWidth","strokeStyle","beginPath","slicewaveWidth","x","y","lineTo","stroke"],"mappings":"AAAAA,kCAAO,CAAC,SAAU,WAAY,4BAA4B,SAAUC,EAAGC,IAAKC,mBAMxED,IAAIE,MAAM,0CAEH,CACHC,QAAS,KACTC,WAAY,KACZC,aAAa,EACbC,aAAc,KACdC,UAAW,KACXC,SAAU,KACVC,iBAAiB,EACjBC,aAAc,EACdC,iBAAkB,GAClBC,aAAc,GAEdC,OAAQ,CACJC,UAAW,KACXC,YAAa,EACbC,SAAU,aAIdC,MAAO,kBACIlB,EAAEmB,QAAO,EAAM,GAAIC,OAI9BC,KAAM,SAASC,WAAYb,SAAUc,kBAE5BD,WAAaA,gBACbb,SAASA,cACTc,YAAaA,iBACbC,eAGLC,OAAOC,aAAeD,OAAOC,cAAgBD,OAAOE,oBAIxDC,OAAQ,aACRC,SAAU,aACVC,QAAS,aAGTN,aAAc,gBACLO,OAAQ/B,EAAE,IAAMoB,KAAKX,SAAW,kBAChCuB,UAAYZ,KAAKW,OAAO,GAAGE,WAAW,OAG/CC,MAAO,eAECC,KAAMf,UAGLb,aAAe,IAAImB,aACpBN,KAAKb,aAAa6B,0BACb5B,UAAYY,KAAKb,aAAa6B,qBAAqBhB,KAAKN,OAAOC,UAAWK,KAAKN,OAAOE,YAAaI,KAAKN,OAAOE,aAC7GI,KAAKb,aAAa8B,2BACpB7B,UAAYY,KAAKb,aAAa8B,sBAAsBjB,KAAKN,OAAOC,UAAWK,KAAKN,OAAOE,YAAaI,KAAKN,OAAOE,aAErHf,IAAIE,MAAM,qDAETK,UAAU8B,QAAQlB,KAAKb,aAAagC,aA6DrC,iBAAkBC,YAClBA,UAAUC,aAAaC,KAAO,kBAC9BC,QAAQ1C,IAAI,kCAIhBuC,UAAUI,aAAaC,aAAa,CAChCC,OAAO,EACPC,OAAO,IACRC,MAnEkB,SAASC,QAC1Bd,KAAKN,SAASoB,QACdd,KAAK7B,aAAc,EACnB6B,KAAKZ,YAAY2B,aAAa,eAAc,GAC5Cf,KAAKgB,OAASF,OAAOG,gBAGjB,IAAIC,EAAE,EAAGA,EAAElB,KAAKgB,OAAOG,OAAQD,IAAI,KAC/BE,MAAQpB,KAAKgB,OAAOE,MACP,SAAdE,MAAMC,KAAgB,KACjBC,SAAWF,MAAMG,cAClBD,SAASE,iBACR1D,IAAIE,MAAM,2BAEVF,IAAIE,MAAM,4BAEXsD,SAASG,iBACR3D,IAAIE,MAAM,2BAEVF,IAAIE,MAAM,6BAMtBgC,KAAK9B,WAAa8B,KAAK5B,aAAasD,wBAAwBZ,QAG5Dd,KAAK9B,WAAWiC,QAAQH,KAAK3B,WAC7B2B,KAAK/B,QAAUF,WAAWgB,QAC1BiB,KAAK/B,QAAQiB,KAAKc,KAAK5B,aAAauD,WAAY,GAGhD3B,KAAK3B,UAAUuD,eAAiB,SAASC,OACrC7B,KAAK/B,QAAQ6D,OAAO9B,KAAK+B,WAAWF,SAGxC7B,KAAKgC,SAAWhC,KAAK5B,aAAa6D,iBAClCjC,KAAK9B,WAAWiC,QAAQH,KAAKgC,UAC7BhC,KAAKgC,SAASE,QAAU,KAExBlC,KAAKmC,aAAenC,KAAKgC,SAASI,kBAClCpC,KAAKqC,aAAe,IAAIC,WAAWtC,KAAKmC,cACxCnC,KAAKuC,WAAa,IAAID,WAAWtC,KAAKmC,cAGtCnC,KAAKH,UAAU2C,UAAU,EAAG,EAAuB,EAApBxC,KAAKJ,OAAO6C,QAA2B,EAAhBzC,KAAKb,YAC3Da,KAAKzB,iBAAiB,EACtByB,KAAKxB,aAAc,EAEnBwB,KAAK0C,SAAWC,aAAY,WACxB3C,KAAK4C,WACL5C,KAAK6C,kBACN,QAckBC,MAAM7D,KAAKU,UAGxCoD,KAAM,WACFC,cAAc/D,KAAKyD,eACd7C,UAAU2C,UAAU,EAAG,EAAuB,EAApBvD,KAAKW,OAAO6C,QAA6B,EAAlBxD,KAAKE,iBACtDhB,aAAc,OACdK,aAAa,OACbD,iBAAgB,OAChBa,YAAY2B,aAAa,eAAc,GAGpB,OAApB9B,KAAKb,cAAmD,WAA5Ba,KAAKb,aAAa6E,YACzC7E,aAAa8E,aAEjB7E,UAAU8E,kBACVnC,OAAOoC,SAAQhC,OAASA,MAAM2B,cAC9BtD,OAAOR,KAAKhB,QAAQoF,WAG7BtB,WAAY,SAASF,eACbyB,QAAU,GACLC,GAAK,EAAGA,GAAK,IAAKA,GACvBD,QAAQC,IAAM1B,MAAM2B,YAAYC,eAAeF,WAE5CD,SAGXT,cAAe,gBAENb,SAAS0B,qBAAqBzE,KAAKsD,gBAEpCoB,IAAM,MACL,IAAIC,OAAQ,EAAGA,OAAQ3E,KAAKsD,WAAWpB,OAAOyC,SAC/CD,KAAO1E,KAAKsD,WAAWqB,QAAU3E,KAAKsD,WAAWqB,YAGjDC,OAASC,KAAKC,KAAKJ,IAAM1E,KAAKsD,WAAWpB,QAG1C0C,OAAS5E,KAAKP,cAAgBO,KAAKV,sBAC7BC,eACFS,KAAKT,cAAcS,KAAKR,uBAClBW,YAAY4E,oBAGhBH,OAAS5E,KAAKP,oBACdH,iBAAkB,OAClBC,aAAa,IAI1BoE,SAAU,eAEFH,MAA8B,EAAtBxD,KAAKW,OAAO6C,aACnBT,SAASiC,sBAAsBhF,KAAKoD,mBAEpCxC,UAAUqE,UAAY,aACtBrE,UAAUsE,SAAS,EAAG,EAAG1B,MAAuB,EAAhBxD,KAAKE,iBAErCU,UAAUuE,UAAY,OACtBvE,UAAUwE,YAAc,YACxBxE,UAAUyE,oBAEXC,eAAiB9B,MAAQxD,KAAKkD,aAC9BqC,EAAI,EAECtD,EAAI,EAAGA,EAAIjC,KAAKkD,aAAcjB,IAAK,KAGpCuD,EADIxF,KAAKoD,aAAanB,GAAK,IACnBjC,KAAKE,WAEP,IAAN+B,QAGKrB,UAAU6E,OAAOF,EAAGC,GAG7BD,GAAKD,oBAGJ1E,UAAU6E,OAAOjC,MAAOxD,KAAKE,iBAC7BU,UAAU8E"} \ No newline at end of file diff --git a/amd/build/audiorecorder.min.js b/amd/build/audiorecorder.min.js new file mode 100644 index 0000000..39bacb5 --- /dev/null +++ b/amd/build/audiorecorder.min.js @@ -0,0 +1,3 @@ +define("qtype_aitext/audiorecorder",["jquery","core/log","core/notification","qtype_aitext/audiohelper","qtype_aitext/browserrec","core/str","qtype_aitext/timer"],(function($,log,notification,audioHelper,browserRec,str,timer){return log.debug("qtype_aitext Audio Recorder: initialising"),{waveHeight:75,audio:{stream:null,blob:null,dataURI:null,start:null,end:null,isRecording:!1,isRecognizing:!1,transcript:null},submitting:!1,controls:{},uniqueid:null,audio_updated:null,maxtime:15e3,passagehash:null,region:null,asrurl:null,lang:null,browserrec:null,usebrowserrec:!1,currentTime:0,stt_guided:!1,currentPrompt:!1,strings:{},clone:function(){return $.extend(!0,{},this)},init:function(opts){var that=this;this.uniqueid=opts.uniqueid,this.callback=opts.callback,this.stt_guided=!!opts.stt_guided&&opts.stt_guided,this.init_strings(),this.prepare_html(),this.register_events();var handle_timer_update=function(){var displaytime=that.timer.fetch_display_time();that.controls.timerstatus.html(displaytime),0==that.timer.seconds&&that.timer.initseconds>0&&(that.update_audio("isRecognizing",!0),that.audiohelper.stop())},on_error=function(error){switch(error.name){case"PermissionDeniedError":case"NotAllowedError":notification.alert("Error",that.strings.allowmicaccess,"OK");break;case"DevicesNotFoundError":case"NotFoundError":notification.alert("Error",that.strings.nomicdetected,"OK");break;default:log.debug("Error",error.name)}};browserRec.will_work_ok()&&!this.stt_guided?(log.debug("using browser rec"),log.debug("arh : "+that.uniqueid),that.browserrec=browserRec.clone(),log.debug("arh : "+that.uniqueid),that.browserrec.init(that.lang,that.waveHeight,that.uniqueid),that.usebrowserrec=!0,that.browserrec.onerror=on_error,that.browserrec.onend=function(){},that.browserrec.onstart=function(){},that.browserrec.onfinalspeechcapture=function(speechtext){that.gotRecognition(speechtext),that.update_audio("isRecording",!1),that.update_audio("isRecognizing",!1)},that.browserrec.oninterimspeechcapture=function(speechtext){that.gotInterimRecognition(speechtext)}):(log.debug("using ds rec"),this.audiohelper=audioHelper.clone(),this.audiohelper.init(this.waveHeight,this.uniqueid,this),that.audiohelper.onError=on_error,that.audiohelper.onStop=function(blob){if(that.timer.stop(),void 0!==blob){var newaudio={blob:blob,dataURI:URL.createObjectURL(blob),end:new Date,isRecording:!1,length:Math.round((that.audio.end-that.audio.start)/1e3)};that.update_audio(newaudio),that.deepSpeech2(that.audio.blob,(function(response){log.debug(response),"success"===response.data.result&&response.data.transcript?that.gotRecognition(response.data.transcript.trim()):notification.alert("Information",that.strings.speechnotrecognized,"OK"),that.update_audio("isRecognizing",!1)}))}},that.audiohelper.onStream=function(stream){var newaudio={stream:stream,isRecording:!0};that.update_audio(newaudio)}),this.timer=timer.clone(),this.timer.init(this.maxtime,handle_timer_update),handle_timer_update()},init_strings:function(){var that=this;str.get_strings([{key:"allowmicaccess",component:"mod_minilesson"},{key:"nomicdetected",component:"mod_minilesson"},{key:"speechnotrecognized",component:"mod_minilesson"}]).done((function(s){var i=0;that.strings.allowmicaccess=s[i++],that.strings.nomicdetected=s[i++],that.strings.speechnotrecognized=s[i++]}))},prepare_html:function(){this.controls.recordercontainer=$(".audiorec_container_"+this.uniqueid),this.controls.recorderbutton=$("."+this.uniqueid+"_recorderdiv"),this.controls.timerstatus=$(".timerstatus_"+this.uniqueid),this.passagehash=this.controls.recorderbutton.data("passagehash"),this.region=this.controls.recorderbutton.data("region"),this.lang=this.controls.recorderbutton.data("lang"),this.asrurl=this.controls.recorderbutton.data("asrurl"),this.maxtime=this.controls.recorderbutton.data("maxtime"),this.waveHeight=this.controls.recorderbutton.data("waveheight")},silence_detected:function(){this.audio.isRecording&&this.toggleRecording()},update_audio:function(newprops,val){if("string"==typeof newprops)log.debug("update_audio:"+newprops+":"+val),this.audio[newprops]!==val&&(this.audio[newprops]=val,this.audio_updated());else{for(var theprop in newprops)this.audio[theprop]=newprops[theprop],log.debug("update_audio:"+theprop+":"+newprops[theprop]);this.audio_updated()}},register_events:function(){var that=this;this.controls.recordercontainer.click((function(){that.toggleRecording()})),this.audio_updated=function(){that.audio.isRecognizing?that.show_recorder_pointer("none"):that.show_recorder_pointer("auto"),that.audio.isRecognizing||that.audio.isRecording?this.controls.recorderbutton.css("background","#e52"):this.controls.recorderbutton.css("background","green"),that.controls.recorderbutton.html(that.recordBtnContent())}},show_recorder_pointer:function(show){show?this.controls.recorderbutton.css("pointer-events","none"):this.controls.recorderbutton.css("pointer-events","auto")},gotRecognition:function(transcript){log.debug("transcript:"+transcript);var message={type:"speech"};message.capturedspeech=transcript,this.callback(message)},gotInterimRecognition:function(transcript){var message={type:"interimspeech"};message.capturedspeech=transcript,this.callback(message)},cleanWord:function(word){return word.replace(/['!"#$%&\\'()\*+,\-\.\/:;<=>?@\[\\\]\^_`{|}~']/g,"").toLowerCase()},recordBtnContent:function(){return this.audio.isRecognizing?'':this.audio.isRecording?'':''},toggleRecording:function(){if(!this.audio.isRecognizing)if(this.audio.isRecording)this.timer.stop(),this.usebrowserrec?(this.update_audio("isRecording",!1),this.update_audio("isRecognizing",!0),this.browserrec.stop()):(this.update_audio("isRecognizing",!0),this.audiohelper.stop());else if(this.currentTime=0,this.timer.reset(),this.timer.start(),this.usebrowserrec)this.update_audio("isRecording",!0),this.browserrec.start();else{var newaudio={stream:null,blob:null,dataURI:null,start:new Date,end:null,isRecording:!1,isRecognizing:!1,transcript:null};this.update_audio(newaudio),this.audiohelper.start()}},deepSpeech2:function(blob,callback){var bodyFormData=new FormData,blobname=this.uniqueid+Math.floor(100*Math.random())+".wav";bodyFormData.append("audioFile",blob,blobname),bodyFormData.append("scorer",this.passagehash),this.stt_guided?bodyFormData.append("strictmode","false"):bodyFormData.append("strictmode","true"),!1!==this.currentPrompt&&bodyFormData.append("prompt",this.currentPrompt),bodyFormData.append("lang",this.lang),bodyFormData.append("wwwroot",M.cfg.wwwroot);var oReq=new XMLHttpRequest;oReq.open("POST",this.asrurl,!0),oReq.onUploadProgress=function(progressEvent){},oReq.onload=function(oEvent){200===oReq.status?callback(JSON.parse(oReq.response)):(callback({data:{result:"error"}}),log.debug(oReq.error))};try{oReq.send(bodyFormData)}catch(err){callback({data:{result:"error"}}),log.debug(err)}}}})); + +//# sourceMappingURL=audiorecorder.min.js.map \ No newline at end of file diff --git a/amd/build/audiorecorder.min.js.map b/amd/build/audiorecorder.min.js.map new file mode 100644 index 0000000..a1d779b --- /dev/null +++ b/amd/build/audiorecorder.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"audiorecorder.min.js","sources":["../src/audiorecorder.js"],"sourcesContent":["define(['jquery', 'core/log','core/notification', 'qtype_aitext/audiohelper','qtype_aitext/browserrec','core/str','qtype_aitext/timer' ],\n function ($, log, notification, audioHelper, browserRec,str, timer) {\n \"use strict\"; // jshint ;_;\n /*\n * The TT recorder\n */\n\n log.debug('qtype_aitext Audio Recorder: initialising');\n\n return {\n waveHeight: 75,\n audio: {\n stream: null,\n blob: null,\n dataURI: null,\n start: null,\n end: null,\n isRecording: false,\n isRecognizing: false,\n transcript: null\n },\n submitting: false,\n controls: {},\n uniqueid: null,\n audio_updated: null,\n maxtime: 15000,\n passagehash: null,\n region: null,\n asrurl: null,\n lang: null,\n browserrec: null,\n usebrowserrec: false,\n currentTime: 0,\n stt_guided: false,\n currentPrompt: false,\n strings: {},\n\n //for making multiple instances\n clone: function () {\n return $.extend(true, {}, this);\n },\n\n init: function(opts){\n var that = this;\n this.uniqueid=opts['uniqueid'];\n this.callback=opts['callback'];\n this.stt_guided = opts['stt_guided'] ? opts['stt_guided'] : false;\n this.init_strings();\n this.prepare_html();\n this.register_events();\n\n // Callbacks.\n\n // Callback: Timer updates.\n var handle_timer_update = function(){\n var displaytime = that.timer.fetch_display_time();\n that.controls.timerstatus.html(displaytime);\n if (that.timer.seconds == 0 && that.timer.initseconds > 0) {\n that.update_audio('isRecognizing', true);\n that.audiohelper.stop();\n }\n };\n\n // Callback: Recorder device errors.\n var on_error = function(error) {\n switch (error.name) {\n case 'PermissionDeniedError':\n case 'NotAllowedError':\n notification.alert(\"Error\",that.strings.allowmicaccess, \"OK\");\n break;\n case 'DevicesNotFoundError':\n case 'NotFoundError':\n notification.alert(\"Error\",that.strings.nomicdetected, \"OK\");\n break;\n default:\n //other errors, like from Edge can fire repeatedly so a notification is not a good idea\n //notification.alert(\"Error\", error.name, \"OK\");\n log.debug(\"Error\", error.name);\n }\n };\n\n // Callback: Recording stopped.\n var on_stopped = function(blob) {\n that.timer.stop()\n\n //if the blob is undefined then the user is super clicking or something\n if(blob===undefined){\n return;\n }\n\n //if ds recc\n var newaudio = {\n blob: blob,\n dataURI: URL.createObjectURL(blob),\n end: new Date(),\n isRecording: false,\n length: Math.round((that.audio.end - that.audio.start) / 1000),\n };\n that.update_audio(newaudio);\n\n that.deepSpeech2(that.audio.blob, function(response){\n log.debug(response);\n if(response.data.result===\"success\" && response.data.transcript){\n that.gotRecognition(response.data.transcript.trim());\n } else {\n notification.alert(\"Information\",that.strings.speechnotrecognized, \"OK\");\n }\n that.update_audio('isRecognizing',false);\n });\n\n };\n\n // Callback: Recorder device got stream - start recording\n var on_gotstream= function(stream) {\n var newaudio={stream: stream, isRecording: true};\n that.update_audio(newaudio);\n\n //TO DO - conditionally start timer here (not toggle recording)\n //so a device error does not cause timer disaster\n // that.timer.reset();\n // that.timer.start();\n \n };\n\n //If browser rec (Chrome Speech Rec) (and ds is optiona)\n if(browserRec.will_work_ok() && ! this.stt_guided){\n //Init browserrec\n log.debug(\"using browser rec\");\n log.debug('arh : ' + that.uniqueid);\n that.browserrec = browserRec.clone();\n log.debug('arh : ' + that.uniqueid);\n that.browserrec.init(that.lang,that.waveHeight,that.uniqueid);\n that.usebrowserrec=true;\n\n //set up events\n that.browserrec.onerror = on_error;\n that.browserrec.onend = function(){\n //do something here\n };\n that.browserrec.onstart = function(){\n //do something here\n };\n that.browserrec.onfinalspeechcapture=function(speechtext){\n that.gotRecognition(speechtext);\n that.update_audio('isRecording',false);\n that.update_audio('isRecognizing',false);\n };\n\n that.browserrec.oninterimspeechcapture=function(speechtext){\n that.gotInterimRecognition(speechtext);\n };\n\n //If DS rec\n }else {\n //set up wav for ds rec\n log.debug(\"using ds rec\");\n this.audiohelper = audioHelper.clone();\n this.audiohelper.init(this.waveHeight,this.uniqueid,this);\n\n that.audiohelper.onError = on_error;\n that.audiohelper.onStop = on_stopped;\n that.audiohelper.onStream = on_gotstream;\n\n }//end of setting up recorders\n\n // Setting up timer.\n this.timer = timer.clone();\n this.timer.init(this.maxtime, handle_timer_update);\n // Init the timer readout\n handle_timer_update();\n },\n\n init_strings: function(){\n var that=this;\n str.get_strings([\n { \"key\": \"allowmicaccess\", \"component\": 'mod_minilesson'},\n { \"key\": \"nomicdetected\", \"component\": 'mod_minilesson'},\n { \"key\": \"speechnotrecognized\", \"component\": 'mod_minilesson'},\n\n ]).done(function (s) {\n var i = 0;\n that.strings.allowmicaccess = s[i++];\n that.strings.nomicdetected = s[i++];\n that.strings.speechnotrecognized = s[i++];\n });\n },\n\n prepare_html: function(){\n this.controls.recordercontainer =$('.audiorec_container_' + this.uniqueid);\n this.controls.recorderbutton = $('.' + this.uniqueid + '_recorderdiv');\n this.controls.timerstatus = $('.timerstatus_' + this.uniqueid);\n this.passagehash = this.controls.recorderbutton.data('passagehash');\n this.region=this.controls.recorderbutton.data('region');\n this.lang=this.controls.recorderbutton.data('lang');\n this.asrurl=this.controls.recorderbutton.data('asrurl');\n this.maxtime=this.controls.recorderbutton.data('maxtime');\n this.waveHeight=this.controls.recorderbutton.data('waveheight');\n },\n\n silence_detected: function(){\n if(this.audio.isRecording){\n this.toggleRecording();\n }\n },\n\n update_audio: function(newprops,val){\n if (typeof newprops === 'string') {\n log.debug('update_audio:' + newprops + ':' + val);\n if (this.audio[newprops] !== val) {\n this.audio[newprops] = val;\n this.audio_updated();\n }\n }else{\n for (var theprop in newprops) {\n this.audio[theprop] = newprops[theprop];\n log.debug('update_audio:' + theprop + ':' + newprops[theprop]);\n }\n this.audio_updated();\n }\n },\n\n register_events: function(){\n var that = this;\n this.controls.recordercontainer.click(function(){\n that.toggleRecording();\n });\n\n this.audio_updated=function() {\n //pointer\n if (that.audio.isRecognizing) {\n that.show_recorder_pointer('none');\n } else {\n that.show_recorder_pointer('auto');\n }\n\n if(that.audio.isRecognizing || that.audio.isRecording ) {\n this.controls.recorderbutton.css('background', '#e52');\n }else{\n this.controls.recorderbutton.css('background', 'green');\n }\n\n //div content WHEN?\n that.controls.recorderbutton.html(that.recordBtnContent());\n };\n\n },\n\n show_recorder_pointer: function(show){\n if(show) {\n this.controls.recorderbutton.css('pointer-events', 'none');\n }else{\n this.controls.recorderbutton.css('pointer-events', 'auto');\n }\n\n },\n\n\n gotRecognition:function(transcript){\n log.debug('transcript:' + transcript);\n var message={};\n message.type='speech';\n message.capturedspeech = transcript;\n //POINT\n this.callback(message);\n },\n\n gotInterimRecognition:function(transcript){\n var message={};\n message.type='interimspeech';\n message.capturedspeech = transcript;\n //POINT\n this.callback(message);\n },\n\n cleanWord: function(word) {\n return word.replace(/['!\"#$%&\\\\'()\\*+,\\-\\.\\/:;<=>?@\\[\\\\\\]\\^_`{|}~']/g,\"\").toLowerCase();\n },\n\n recordBtnContent: function() {\n\n if(!this.audio.isRecognizing){\n\n if (this.audio.isRecording) {\n return '';\n } else {\n return '';\n }\n\n } else {\n return '';\n }\n },\n toggleRecording: function() {\n var that =this;\n //If we are recognizing, then we want to discourage super click'ers\n if (this.audio.isRecognizing) {\n return;\n }\n\n //If we are current recording\n if (this.audio.isRecording) {\n that.timer.stop();\n\n //If using Browser Rec (chrome speech)\n if(this.usebrowserrec){ \n that.update_audio('isRecording',false);\n that.update_audio('isRecognizing',true);\n this.browserrec.stop();\n\n //If using DS rec\n }else{\n this.update_audio('isRecognizing',true);\n this.audiohelper.stop();\n }\n\n //If we are NOT currently recording\n } else {\n // Run the timer\n that.currentTime = 0;\n that.timer.reset();\n that.timer.start();\n \n\n //If using Browser Rec (chrome speech)\n if(this.usebrowserrec){\n this.update_audio('isRecording',true);\n this.browserrec.start();\n\n //If using DS Rec\n }else {\n var newaudio = {\n stream: null,\n blob: null,\n dataURI: null,\n start: new Date(),\n end: null,\n isRecording: false,\n isRecognizing:false,\n transcript: null\n };\n this.update_audio(newaudio);\n this.audiohelper.start();\n }\n }\n },\n\n\n deepSpeech2: function(blob, callback) {\n var bodyFormData = new FormData();\n var blobname = this.uniqueid + Math.floor(Math.random() * 100) + '.wav';\n bodyFormData.append('audioFile', blob, blobname);\n bodyFormData.append('scorer', this.passagehash);\n if(this.stt_guided) {\n bodyFormData.append('strictmode', 'false');\n }else{\n bodyFormData.append('strictmode', 'true');\n }\n //prompt is used by whisper and other transcibers down the line\n if(this.currentPrompt!==false){\n bodyFormData.append('prompt', this.currentPrompt);\n }\n bodyFormData.append('lang', this.lang);\n bodyFormData.append('wwwroot', M.cfg.wwwroot);\n\n var oReq = new XMLHttpRequest();\n oReq.open(\"POST\", this.asrurl, true);\n oReq.onUploadProgress= function(progressEvent) {};\n oReq.onload = function(oEvent) {\n if (oReq.status === 200) {\n callback(JSON.parse(oReq.response));\n } else {\n callback({data: {result: \"error\"}});\n log.debug(oReq.error);\n }\n };\n try {\n oReq.send(bodyFormData);\n }catch(err){\n callback({data: {result: \"error\"}});\n log.debug(err);\n }\n },\n\n };//end of return value\n\n});"],"names":["define","$","log","notification","audioHelper","browserRec","str","timer","debug","waveHeight","audio","stream","blob","dataURI","start","end","isRecording","isRecognizing","transcript","submitting","controls","uniqueid","audio_updated","maxtime","passagehash","region","asrurl","lang","browserrec","usebrowserrec","currentTime","stt_guided","currentPrompt","strings","clone","extend","this","init","opts","that","callback","init_strings","prepare_html","register_events","handle_timer_update","displaytime","fetch_display_time","timerstatus","html","seconds","initseconds","update_audio","audiohelper","stop","on_error","error","name","alert","allowmicaccess","nomicdetected","will_work_ok","onerror","onend","onstart","onfinalspeechcapture","speechtext","gotRecognition","oninterimspeechcapture","gotInterimRecognition","onError","onStop","undefined","newaudio","URL","createObjectURL","Date","length","Math","round","deepSpeech2","response","data","result","trim","speechnotrecognized","onStream","get_strings","done","s","i","recordercontainer","recorderbutton","silence_detected","toggleRecording","newprops","val","theprop","click","show_recorder_pointer","css","recordBtnContent","show","message","capturedspeech","cleanWord","word","replace","toLowerCase","reset","bodyFormData","FormData","blobname","floor","random","append","M","cfg","wwwroot","oReq","XMLHttpRequest","open","onUploadProgress","progressEvent","onload","oEvent","status","JSON","parse","send","err"],"mappings":"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"} \ No newline at end of file diff --git a/amd/build/browserrec.min.js b/amd/build/browserrec.min.js new file mode 100644 index 0000000..0cc1c4e --- /dev/null +++ b/amd/build/browserrec.min.js @@ -0,0 +1,3 @@ +define("qtype_aitext/browserrec",["jquery","core/log"],(function($,log){return log.debug("qtype_aitext browser speech rec: initialising"),{recognition:null,recognizing:!1,final_transcript:"",interim_transcript:"",start_timestamp:0,lang:"en-US",interval:0,browsertype:"",clone:function(){return $.extend(!0,{},this)},will_work_ok:function(opts){void 0!==navigator.brave&&(this.browsertype="brave"),navigator.userAgent.toLowerCase().indexOf("edg/")>-1&&""===this.browsertype&&(this.browsertype="edge");var has_chrome=navigator.userAgent.indexOf("Chrome")>-1;navigator.userAgent.indexOf("Safari")>-1&&!has_chrome&&""===this.browsertype&&(this.browsertype="safari");var hasspeechrec="webkitSpeechRecognition"in window||"SpeechRecognition"in window;return hasspeechrec&&""===this.browsertype&&has_chrome&&(this.browsertype="chrome"),hasspeechrec},init:function(lang,waveheight,uniqueid){log.debug("bh : "+uniqueid);var SpeechRecognition=SpeechRecognition||webkitSpeechRecognition;this.recognition=new SpeechRecognition,this.recognition.continuous=!0,this.recognition.interimResults=!0,this.lang=lang,this.waveHeight=waveheight,this.uniqueid=uniqueid,this.prepare_html(),this.register_events()},prepare_html:function(){this.canvas=$("."+this.uniqueid+"_waveform"),this.canvasCtx=this.canvas[0].getContext("2d")},set_grammar:function(grammar){var SpeechGrammarList=SpeechGrammarList||webkitSpeechGrammarList;if(SpeechGrammarList){var speechRecognitionList=new SpeechGrammarList;speechRecognitionList.addFromString(grammar,1),this.recognition.grammars=speechRecognitionList}},start:function(){var that=this;this.recognizing||(this.recognizing=!0,this.final_transcript="",this.interim_transcript="",this.recognition.lang=this.lang,this.recognition.start(),this.start_timestamp=Date.now(),that.onstart(),that.interval=setInterval((function(){that.drawWave()}),100))},stop:function(){var that=this;this.recognizing=!1,this.recognition.stop(),clearInterval(this.interval),this.canvasCtx.clearRect(0,0,2*this.canvas.width(),2*this.waveHeight),setTimeout((function(){that.onfinalspeechcapture(that.final_transcript)}),1e3),this.onend()},register_events:function(){var recognition=this.recognition,that=this;recognition.onerror=function(event){"no-speech"==event.error&&log.debug("info_no_speech"),"audio-capture"==event.error&&log.debug("info_no_microphone"),"not-allowed"==event.error&&(event.timeStamp-that.start_timestamp<100?log.debug("info_blocked"):log.debug("info_denied")),that.onerror({error:{name:event.error}})},recognition.onend=function(){that.recognizing&&that.recognition.start()},recognition.onresult=function(event){for(var i=event.resultIndex;i -1;\n if(edge && this.browsertype === ''){\n this.browsertype = 'edge';\n //return false;\n }\n\n //Safari may or may not work, but its hard to tell from the browser agent\n var has_chrome = navigator.userAgent.indexOf('Chrome') > -1;\n var has_safari = navigator.userAgent.indexOf(\"Safari\") > -1;\n var safari = has_safari && !has_chrome;\n if(safari && this.browsertype === ''){\n this.browsertype = 'safari';\n //return false;\n }\n\n //This is feature detection, and for chrome it can be trusted.\n var hasspeechrec = ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);\n if(hasspeechrec && this.browsertype === '' && has_chrome){\n this.browsertype = 'chrome';\n }\n\n //This is feature detection, and for chrome it can be trusted.\n // The others might say they do speech rec, but that does not mean it works\n return hasspeechrec;\n\n },\n\n init: function (lang,waveheight,uniqueid) {\n log.debug('bh : ' + uniqueid);\n var SpeechRecognition = SpeechRecognition || webkitSpeechRecognition;\n this.recognition = new SpeechRecognition();\n this.recognition.continuous = true;\n this.recognition.interimResults = true;\n this.lang = lang;\n this.waveHeight = waveheight;\n this.uniqueid = uniqueid;\n this.prepare_html();\n this.register_events();\n },\n\n prepare_html: function(){\n this.canvas =$('.' + this.uniqueid + \"_waveform\");\n this.canvasCtx = this.canvas[0].getContext(\"2d\");\n },\n\n set_grammar: function (grammar) {\n var SpeechGrammarList = SpeechGrammarList || webkitSpeechGrammarList;\n if (SpeechGrammarList) {\n var speechRecognitionList = new SpeechGrammarList();\n speechRecognitionList.addFromString(grammar, 1);\n this.recognition.grammars = speechRecognitionList;\n }\n },\n\n start: function () {\n var that =this;\n\n //If we already started ignore this\n if (this.recognizing) {\n return;\n }\n this.recognizing = true;\n this.final_transcript = '';\n this.interim_transcript = '';\n this.recognition.lang = this.lang;//select_dialect.value;\n this.recognition.start();\n this.start_timestamp = Date.now();//event.timeStamp;\n that.onstart();\n\n\n //kick off animation\n that.interval = setInterval(function() {\n that.drawWave();\n }, 100);\n },\n\n stop: function () {\n var that=this;\n this.recognizing = false;\n this.recognition.stop();\n clearInterval(this.interval);\n this.canvasCtx.clearRect(0, 0, this.canvas.width()*2, this.waveHeight * 2);\n setTimeout(function() {\n that.onfinalspeechcapture(that.final_transcript);\n }, 1000);\n this.onend();\n },\n\n register_events: function () {\n\n var recognition = this.recognition;\n var that = this;\n\n recognition.onerror = function (event) {\n if (event.error == 'no-speech') {\n log.debug('info_no_speech');\n }\n if (event.error == 'audio-capture') {\n log.debug('info_no_microphone');\n }\n if (event.error == 'not-allowed') {\n if (event.timeStamp - that.start_timestamp < 100) {\n log.debug('info_blocked');\n } else {\n log.debug('info_denied');\n }\n }\n that.onerror({error: {name: event.error}});\n };\n\n recognition.onend = function () {\n if(that.recognizing){\n that.recognition.start();\n }\n\n };\n\n recognition.onresult = function (event) {\n for (var i = event.resultIndex; i < event.results.length; ++i) {\n if (event.results[i].isFinal) {\n that.final_transcript += event.results[i][0].transcript;\n } else {\n var provisional_transcript = that.final_transcript + event.results[i][0].transcript;\n //the interim and final events do not arrive in sequence, we dont want the length going down, its weird\n //so just dont respond when the sequence is wonky\n if(provisional_transcript.length < that.interim_transcript.length){\n return;\n }else{\n that.interim_transcript = provisional_transcript;\n }\n that.oninterimspeechcapture(that.interim_transcript);\n }\n }\n\n };\n },//end of register events\n\n drawWave: function() {\n\n var width = this.canvas.width() * 2;\n var bufferLength=4096;\n\n this.canvasCtx.fillStyle = 'white';\n this.canvasCtx.fillRect(0, 0, width, this.waveHeight*2);\n\n this.canvasCtx.lineWidth = 5;\n this.canvasCtx.strokeStyle = 'gray';\n this.canvasCtx.beginPath();\n\n var slicewaveWidth = width / bufferLength;\n var x = 0;\n\n for (var i = 0; i < bufferLength; i++) {\n\n var v = ((Math.random() * 64) + 96) / 128.0;\n var y = v * this.waveHeight;\n\n if (i === 0) {\n // this.canvasCtx.moveTo(x, y);\n } else {\n this.canvasCtx.lineTo(x, y);\n }\n x += slicewaveWidth;\n }\n\n this.canvasCtx.lineTo(width, this.waveHeight);\n this.canvasCtx.stroke();\n\n },\n\n onstart: function () {\n log.debug('started');\n },\n onerror: function () {\n log.debug('error');\n },\n onend: function () {\n log.debug('end');\n },\n onfinalspeechcapture: function (speechtext) {\n log.debug(speechtext);\n },\n oninterimspeechcapture: function (speechtext) {\n // log.debug(speechtext);\n }\n\n };//end of returned object\n});//total end\n"],"names":["define","$","log","debug","recognition","recognizing","final_transcript","interim_transcript","start_timestamp","lang","interval","browsertype","clone","extend","this","will_work_ok","opts","navigator","brave","userAgent","toLowerCase","indexOf","has_chrome","hasspeechrec","window","init","waveheight","uniqueid","SpeechRecognition","webkitSpeechRecognition","continuous","interimResults","waveHeight","prepare_html","register_events","canvas","canvasCtx","getContext","set_grammar","grammar","SpeechGrammarList","webkitSpeechGrammarList","speechRecognitionList","addFromString","grammars","start","that","Date","now","onstart","setInterval","drawWave","stop","clearInterval","clearRect","width","setTimeout","onfinalspeechcapture","onend","onerror","event","error","timeStamp","name","onresult","i","resultIndex","results","length","isFinal","transcript","provisional_transcript","oninterimspeechcapture","fillStyle","fillRect","lineWidth","strokeStyle","beginPath","slicewaveWidth","x","y","Math","random","lineTo","stroke","speechtext"],"mappings":"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"} \ No newline at end of file diff --git a/amd/build/showprompt.min.js.map b/amd/build/showprompt.min.js.map index 83f2dcb..8307f39 100644 --- a/amd/build/showprompt.min.js.map +++ b/amd/build/showprompt.min.js.map @@ -1 +1 @@ -{"version":3,"file":"showprompt.min.js","sources":["../src/showprompt.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Display a button in testing to reveal the prompt that was sent\n *\n * @module qtype_aitext/showprompt\n * @copyright 2024 Marcus Green\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport const init = () => {\n var button = document.getElementById('showprompt');\n button.addEventListener('click', (event) => {\n toggleFullPrompt(event);\n });\n /**\n * Togle the visibility of the prompt that is sent to\n * the AI System\n * @param {*} event\n */\n function toggleFullPrompt(event) {\n event.preventDefault();\n var text = document.getElementById(\"fullprompt\");\n if (text.className === \"hidden\") {\n text.className = \"visible\";\n } else {\n text.className = \"hidden\";\n }\n }\n};\n"],"names":["document","getElementById","addEventListener","event","preventDefault","text","className","toggleFullPrompt"],"mappings":"4JAsBoB,KACHA,SAASC,eAAe,cAC9BC,iBAAiB,SAAUC,kBAQRA,OACtBA,MAAMC,qBACFC,KAAOL,SAASC,eAAe,cACZ,WAAnBI,KAAKC,UACLD,KAAKC,UAAY,UAEjBD,KAAKC,UAAY,SAbrBC,CAAiBJ"} \ No newline at end of file +{"version":3,"file":"showprompt.min.js","sources":["../src/showprompt.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Display a button in testing to reveal the prompt that was sent\n *\n * @module qtype_aitext/showprompt\n * @copyright 2024 Marcus Green\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport const init = () => {\n var button = document.getElementById('showprompt');\n button.addEventListener('click', (event) => {\n toggleFullPrompt(event);\n });\n\n /**\n * Togle the visibility of the prompt that is sent to\n * the AI System\n * @param {*} event\n */\n function toggleFullPrompt(event) {\n event.preventDefault();\n var text = document.getElementById(\"fullprompt\");\n if (text.className === \"hidden\") {\n text.className = \"visible\";\n } else {\n text.className = \"hidden\";\n }\n }\n};\n"],"names":["document","getElementById","addEventListener","event","preventDefault","text","className","toggleFullPrompt"],"mappings":"4JAsBoB,KACHA,SAASC,eAAe,cAC9BC,iBAAiB,SAAUC,kBASRA,OACtBA,MAAMC,qBACFC,KAAOL,SAASC,eAAe,cACZ,WAAnBI,KAAKC,UACLD,KAAKC,UAAY,UAEjBD,KAAKC,UAAY,SAdrBC,CAAiBJ"} \ No newline at end of file diff --git a/amd/build/timer.min.js b/amd/build/timer.min.js new file mode 100644 index 0000000..d78484f --- /dev/null +++ b/amd/build/timer.min.js @@ -0,0 +1,3 @@ +define("qtype_aitext/timer",["jquery","core/log"],(function($,log){return log.debug("Timer: initialising"),{increment:1,initseconds:0,seconds:0,finalseconds:0,intervalhandle:null,callback:null,enabled:!1,clone:function(){return $.extend(!0,{},this)},init:function(initseconds,callback){this.initseconds=parseInt(initseconds),this.seconds=parseInt(initseconds),this.callback=callback,this.enabled=!0},start:function(){if(this.enabled){var self=this;this.finalseconds=0,this.initseconds>0?this.increment=-1:this.increment=1,this.intervalhandle=this.customSetInterval((function(){self.seconds=self.seconds+self.increment,self.finalseconds=self.finalseconds+1,self.callback()}),1e3)}},customSetInterval:function(func,time){var lastTime=Date.now(),lastDelay=time,outp={};return outp.id=setTimeout((function tick(){var now=Date.now(),dTime=now-lastTime;lastTime=now,lastDelay=time+lastDelay-dTime,outp.id=setTimeout(tick,lastDelay),func()}),time),outp},disable:function(){this.enabled=!1},enable:function(){this.enabled=!0},fetch_display_time:function(someseconds){someseconds||(someseconds=this.seconds);var theHours="00"+parseInt(someseconds/3600);theHours=theHours.substr(theHours.length-2,2);var theMinutes="00"+parseInt(someseconds/60);theMinutes=theMinutes.substr(theMinutes.length-2,2);var theSeconds="00"+parseInt(someseconds%60);return theHours+":"+theMinutes+":"+(theSeconds=theSeconds.substr(theSeconds.length-2,2))},stop:function(){clearTimeout(this.intervalhandle.id)},reset:function(){this.seconds=this.initseconds},pause:function(){this.increment=0},resume:function(){this.initseconds>0?this.increment=-1:this.increment=1}}})); + +//# sourceMappingURL=timer.min.js.map \ No newline at end of file diff --git a/amd/build/timer.min.js.map b/amd/build/timer.min.js.map new file mode 100644 index 0000000..4e83167 --- /dev/null +++ b/amd/build/timer.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"timer.min.js","sources":["../src/timer.js"],"sourcesContent":["/* jshint ignore:start */\ndefine(['jquery', 'core/log'], function ($, log) {\n\n \"use strict\"; // jshint ;_;\n\n log.debug('Timer: initialising');\n\n return {\n increment: 1,\n initseconds: 0,\n seconds: 0,\n finalseconds: 0,\n intervalhandle: null,\n callback: null,\n enabled: false,\n\n //for making multiple instances\n clone: function () {\n return $.extend(true, {}, this);\n },\n\n init: function (initseconds, callback) {\n this.initseconds = parseInt(initseconds);\n this.seconds = parseInt(initseconds);\n this.callback = callback;\n this.enabled = true;\n },\n\n start: function () {\n if (!this.enabled) {\n return;\n }\n\n var self = this;\n this.finalseconds = 0;\n if (this.initseconds > 0) {\n this.increment = -1;\n } else {\n this.increment = 1;\n }\n this.intervalhandle = this.customSetInterval(function () {\n self.seconds = self.seconds + self.increment;\n self.finalseconds = self.finalseconds + 1;\n self.callback();\n }, 1000);\n },\n\n //we use a custom set interval which self adjusts for inaccuracies.\n customSetInterval: function (func, time) {\n var lastTime = Date.now(),\n lastDelay = time,\n outp = {};\n\n function tick() {\n var now = Date.now(),\n dTime = now - lastTime;\n\n lastTime = now;\n lastDelay = time + lastDelay - dTime;\n outp.id = setTimeout(tick, lastDelay);\n func();\n\n }\n\n outp.id = setTimeout(tick, time);\n return outp;\n },\n\n disable: function () {\n this.enabled = false;\n },\n\n enable: function () {\n this.enabled = true;\n },\n\n fetch_display_time: function (someseconds) {\n if (!someseconds) {\n someseconds = this.seconds;\n }\n var theHours = '00' + parseInt(someseconds / 3600);\n theHours = theHours.substr(theHours.length - 2, 2);\n var theMinutes = '00' + parseInt(someseconds / 60);\n theMinutes = theMinutes.substr(theMinutes.length - 2, 2);\n var theSeconds = '00' + parseInt(someseconds % 60);\n theSeconds = theSeconds.substr(theSeconds.length - 2, 2);\n var display_time = theHours + ':' + theMinutes + ':' + theSeconds;\n return display_time;\n },\n\n stop: function () {\n clearTimeout(this.intervalhandle.id);\n },\n\n reset: function () {\n this.seconds = this.initseconds;\n },\n\n pause: function () {\n this.increment = 0;\n },\n resume: function () {\n if (this.initseconds > 0) {\n this.increment = -1;\n } else {\n this.increment = 1;\n }\n }\n\n };//end of returned object\n});//total end\n"],"names":["define","$","log","debug","increment","initseconds","seconds","finalseconds","intervalhandle","callback","enabled","clone","extend","this","init","parseInt","start","self","customSetInterval","func","time","lastTime","Date","now","lastDelay","outp","id","setTimeout","tick","dTime","disable","enable","fetch_display_time","someseconds","theHours","substr","length","theMinutes","theSeconds","stop","clearTimeout","reset","pause","resume"],"mappings":"AACAA,4BAAO,CAAC,SAAU,aAAa,SAAUC,EAAGC,YAIxCA,IAAIC,MAAM,uBAEH,CACHC,UAAW,EACXC,YAAa,EACbC,QAAS,EACTC,aAAc,EACdC,eAAgB,KAChBC,SAAU,KACVC,SAAS,EAGTC,MAAO,kBACIV,EAAEW,QAAO,EAAM,GAAIC,OAG9BC,KAAM,SAAUT,YAAaI,eACpBJ,YAAcU,SAASV,kBACvBC,QAAUS,SAASV,kBACnBI,SAAWA,cACXC,SAAU,GAGnBM,MAAO,cACEH,KAAKH,aAINO,KAAOJ,UACNN,aAAe,EAChBM,KAAKR,YAAc,OACdD,WAAa,OAEbA,UAAY,OAEhBI,eAAiBK,KAAKK,mBAAkB,WACzCD,KAAKX,QAAUW,KAAKX,QAAUW,KAAKb,UACnCa,KAAKV,aAAeU,KAAKV,aAAe,EACxCU,KAAKR,aACN,OAIPS,kBAAmB,SAAUC,KAAMC,UAC3BC,SAAWC,KAAKC,MAChBC,UAAYJ,KACZK,KAAO,UAaXA,KAAKC,GAAKC,qBAXDC,WACDL,IAAMD,KAAKC,MACXM,MAAQN,IAAMF,SAElBA,SAAWE,IACXC,UAAYJ,KAAOI,UAAYK,MAC/BJ,KAAKC,GAAKC,WAAWC,KAAMJ,WAC3BL,SAIuBC,MACpBK,MAGXK,QAAS,gBACApB,SAAU,GAGnBqB,OAAQ,gBACCrB,SAAU,GAGnBsB,mBAAoB,SAAUC,aACrBA,cACDA,YAAcpB,KAAKP,aAEnB4B,SAAW,KAAOnB,SAASkB,YAAc,MAC7CC,SAAWA,SAASC,OAAOD,SAASE,OAAS,EAAG,OAC5CC,WAAa,KAAOtB,SAASkB,YAAc,IAC/CI,WAAaA,WAAWF,OAAOE,WAAWD,OAAS,EAAG,OAClDE,WAAa,KAAOvB,SAASkB,YAAc,WAE5BC,SAAW,IAAMG,WAAa,KADjDC,WAAaA,WAAWH,OAAOG,WAAWF,OAAS,EAAG,KAK1DG,KAAM,WACFC,aAAa3B,KAAKL,eAAekB,KAGrCe,MAAO,gBACEnC,QAAUO,KAAKR,aAGxBqC,MAAO,gBACEtC,UAAY,GAErBuC,OAAQ,WACA9B,KAAKR,YAAc,OACdD,WAAa,OAEbA,UAAY"} \ No newline at end of file diff --git a/amd/build/transcriptmanager.min.js b/amd/build/transcriptmanager.min.js new file mode 100644 index 0000000..04a4798 --- /dev/null +++ b/amd/build/transcriptmanager.min.js @@ -0,0 +1,3 @@ +define("qtype_aitext/transcriptmanager",["jquery","core/log","qtype_aitext/audiorecorder"],(function($,log,audiorecorder){return log.debug("qtype_aitext transcriptmanager: initialising"),{audiorec:null,clone:function(){return $.extend(!0,{},this)},init:function(opts){this.register_events(opts),this.init_components(opts)},register_events:function(opts){$(".retry_"+opts.uniqueid).on("click",(function(){$(".qtype_aitext_audiorecorder_"+opts.uniqueid).removeClass("hidden"),$(".qtype_aitext_audiosummary_"+opts.uniqueid).addClass("hidden")}))},init_components:function(opts){var app=this;opts.callback=function(message){switch(message.type){case"recording":break;case"interimspeech":var wordcount=app.count_words(message.capturedspeech);$("."+opts.uniqueid+"_currentwordcount").text(wordcount);break;case"speech":log.debug("speech at multiaudio");var speechtext=message.capturedspeech;log.debug("speechtext:",speechtext),$("."+opts.uniqueid).val(speechtext);wordcount=app.count_words(message.capturedspeech);$("."+opts.uniqueid+"_currentwordcount").text(wordcount),$(".qtype_aitext_audiorecorder_"+opts.uniqueid).addClass("hidden"),$(".qtype_aitext_audiosummary_"+opts.uniqueid).removeClass("hidden")}},opts.stt_guided=!1,app.audiorec=audiorecorder.clone(),app.audiorec.init(opts)},count_words:function(transcript){return transcript.trim().split(/\s+/).filter((function(word){return word.length>0})).length}}})); + +//# sourceMappingURL=transcriptmanager.min.js.map \ No newline at end of file diff --git a/amd/build/transcriptmanager.min.js.map b/amd/build/transcriptmanager.min.js.map new file mode 100644 index 0000000..e5effbe --- /dev/null +++ b/amd/build/transcriptmanager.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"transcriptmanager.min.js","sources":["../src/transcriptmanager.js"],"sourcesContent":["define(['jquery',\n 'core/log',\n 'qtype_aitext/audiorecorder'\n ], function($, log, audiorecorder) {\n \"use strict\"; // jshint ;_;\n\n /*\n This file is to manage the quiz stage\n */\n\n log.debug('qtype_aitext transcriptmanager: initialising');\n\n return {\n\n //a handle on the audio recorder\n audiorec: null,\n \n //for making multiple instances .. for making multiple instances .. for making multiple instances .. multiple..\n clone: function () {\n return $.extend(true, {}, this);\n },\n\n init: function(opts) {\n this.register_events(opts);\n this.init_components(opts);\n },\n\n register_events: function(opts) {\n var self = this;\n $('.retry_' + opts.uniqueid).on('click', function() {\n $('.qtype_aitext_audiorecorder_' + opts.uniqueid).removeClass('hidden');\n $('.qtype_aitext_audiosummary_' + opts.uniqueid).addClass('hidden');\n });\n },//end of register events\n\n init_components: function(opts) {\n var app= this;\n var theCallback = function(message) {\n\n switch (message.type) {\n case 'recording':\n break;\n case 'interimspeech':\n var wordcount = app.count_words(message.capturedspeech);\n $('.' + opts.uniqueid + '_currentwordcount').text(wordcount);\n break;\n case 'speech':\n log.debug(\"speech at multiaudio\");\n var speechtext = message.capturedspeech;\n log.debug('speechtext:',speechtext);\n\n //set speech text to the hidden input\n $('.' + opts.uniqueid).val(speechtext);\n\n //update the wordcount\n var wordcount = app.count_words(message.capturedspeech);\n $('.' + opts.uniqueid + '_currentwordcount').text(wordcount);\n\n //hide the recorder and show the summary\n $('.qtype_aitext_audiorecorder_' + opts.uniqueid).addClass('hidden');\n $('.qtype_aitext_audiosummary_' + opts.uniqueid).removeClass('hidden');\n\n\n } //end of switch message type\n };\n\n //init audio recorder\n opts.callback = theCallback;\n opts.stt_guided=false;\n app.audiorec = audiorecorder.clone();\n app.audiorec.init(opts);\n }, //end of init components\n\n count_words: function(transcript) {\n return transcript.trim().split(/\\s+/).filter(function(word) {\n return word.length > 0;\n }).length;\n }\n };\n});"],"names":["define","$","log","audiorecorder","debug","audiorec","clone","extend","this","init","opts","register_events","init_components","uniqueid","on","removeClass","addClass","app","callback","message","type","wordcount","count_words","capturedspeech","text","speechtext","val","stt_guided","transcript","trim","split","filter","word","length"],"mappings":"AAAAA,wCAAO,CAAC,SACJ,WACA,+BACG,SAASC,EAAGC,IAAMC,sBAOvBD,IAAIE,MAAM,gDAEH,CAGHC,SAAU,KAGVC,MAAO,kBACIL,EAAEM,QAAO,EAAM,GAAIC,OAGhCC,KAAM,SAASC,WACRC,gBAAgBD,WAChBE,gBAAgBF,OAGvBC,gBAAiB,SAASD,MAExBT,EAAE,UAAYS,KAAKG,UAAUC,GAAG,SAAS,WACvCb,EAAE,+BAAiCS,KAAKG,UAAUE,YAAY,UAC9Dd,EAAE,8BAAgCS,KAAKG,UAAUG,SAAS,cAI9DJ,gBAAiB,SAASF,UAClBO,IAAKT,KA+BTE,KAAKQ,SA9Ba,SAASC,gBAEfA,QAAQC,UACP,sBAEA,oBACGC,UAAYJ,IAAIK,YAAYH,QAAQI,gBACxCtB,EAAE,IAAMS,KAAKG,SAAW,qBAAqBW,KAAKH,qBAEjD,SACDnB,IAAIE,MAAM,4BACNqB,WAAaN,QAAQI,eACzBrB,IAAIE,MAAM,cAAcqB,YAGxBxB,EAAE,IAAMS,KAAKG,UAAUa,IAAID,YAGvBJ,UAAYJ,IAAIK,YAAYH,QAAQI,gBACxCtB,EAAE,IAAMS,KAAKG,SAAW,qBAAqBW,KAAKH,WAGlDpB,EAAE,+BAAiCS,KAAKG,UAAUG,SAAS,UAC3Df,EAAE,8BAAgCS,KAAKG,UAAUE,YAAY,YAQzEL,KAAKiB,YAAW,EAChBV,IAAIZ,SAAWF,cAAcG,QAC7BW,IAAIZ,SAASI,KAAKC,OAGtBY,YAAa,SAASM,mBACXA,WAAWC,OAAOC,MAAM,OAAOC,QAAO,SAASC,aAC3CA,KAAKC,OAAS,KACtBA"} \ No newline at end of file diff --git a/amd/build/wavencoder.min.js b/amd/build/wavencoder.min.js new file mode 100644 index 0000000..4cdfb7e --- /dev/null +++ b/amd/build/wavencoder.min.js @@ -0,0 +1,3 @@ +define("qtype_aitext/wavencoder",["jquery","core/log"],(function($,log){return log.debug("qtype_aitext Wav Encoder initialising"),{clone:function(){return $.extend(!0,{},this)},init:function(sampleRate,numChannels){this.sampleRate=sampleRate,this.numChannels=numChannels,this.numSamples=0,this.dataViews=[]},encode:function(buffer){if(void 0!==this.dataViews){for(var len=buffer[0].length,nCh=this.numChannels,view=new DataView(new ArrayBuffer(len*nCh*2)),offset=0,i=0;i track.stop()); + this.onStop(this.encoder.finish()); + }, + + getBuffers: function(event) { + var buffers = []; + for (var ch = 0; ch < 2; ++ch) { + buffers[ch] = event.inputBuffer.getChannelData(ch); + } + return buffers; + }, + + detectSilence: function () { + + this.listener.getByteFrequencyData(this.volumeData); + + let sum = 0; + for (var vindex =0; vindex =this.silenceintervals){ + this.therecorder.silence_detected(); + } + //if we have a sound, reset silence count to zero, and flag that we have started + }else if(volume > this.silencelevel){ + this.alreadyhadsound = true; + this.silencecount=0; + } + }, + + drawWave: function() { + + var width = this.canvas.width() * 2; + this.listener.getByteTimeDomainData(this.analyserData); + + this.canvasCtx.fillStyle = 'white'; + this.canvasCtx.fillRect(0, 0, width, this.waveHeight*2); + + this.canvasCtx.lineWidth = 5; + this.canvasCtx.strokeStyle = 'gray'; + this.canvasCtx.beginPath(); + + var slicewaveWidth = width / this.bufferLength; + var x = 0; + + for (var i = 0; i < this.bufferLength; i++) { + + var v = this.analyserData[i] / 128.0; + var y = v * this.waveHeight; + + if (i === 0) { + // this.canvasCtx.moveTo(x, y); + } else { + this.canvasCtx.lineTo(x, y); + } + + x += slicewaveWidth; + } + + this.canvasCtx.lineTo(width, this.waveHeight); + this.canvasCtx.stroke(); + + } + }; //end of this declaration + + +}); \ No newline at end of file diff --git a/amd/src/audiorecorder.js b/amd/src/audiorecorder.js new file mode 100644 index 0000000..4b0131f --- /dev/null +++ b/amd/src/audiorecorder.js @@ -0,0 +1,386 @@ +define(['jquery', 'core/log','core/notification', 'qtype_aitext/audiohelper','qtype_aitext/browserrec','core/str','qtype_aitext/timer' ], + function ($, log, notification, audioHelper, browserRec,str, timer) { + "use strict"; // jshint ;_; + /* + * The TT recorder + */ + + log.debug('qtype_aitext Audio Recorder: initialising'); + + return { + waveHeight: 75, + audio: { + stream: null, + blob: null, + dataURI: null, + start: null, + end: null, + isRecording: false, + isRecognizing: false, + transcript: null + }, + submitting: false, + controls: {}, + uniqueid: null, + audio_updated: null, + maxtime: 15000, + passagehash: null, + region: null, + asrurl: null, + lang: null, + browserrec: null, + usebrowserrec: false, + currentTime: 0, + stt_guided: false, + currentPrompt: false, + strings: {}, + + //for making multiple instances + clone: function () { + return $.extend(true, {}, this); + }, + + init: function(opts){ + var that = this; + this.uniqueid=opts['uniqueid']; + this.callback=opts['callback']; + this.stt_guided = opts['stt_guided'] ? opts['stt_guided'] : false; + this.init_strings(); + this.prepare_html(); + this.register_events(); + + // Callbacks. + + // Callback: Timer updates. + var handle_timer_update = function(){ + var displaytime = that.timer.fetch_display_time(); + that.controls.timerstatus.html(displaytime); + if (that.timer.seconds == 0 && that.timer.initseconds > 0) { + that.update_audio('isRecognizing', true); + that.audiohelper.stop(); + } + }; + + // Callback: Recorder device errors. + var on_error = function(error) { + switch (error.name) { + case 'PermissionDeniedError': + case 'NotAllowedError': + notification.alert("Error",that.strings.allowmicaccess, "OK"); + break; + case 'DevicesNotFoundError': + case 'NotFoundError': + notification.alert("Error",that.strings.nomicdetected, "OK"); + break; + default: + //other errors, like from Edge can fire repeatedly so a notification is not a good idea + //notification.alert("Error", error.name, "OK"); + log.debug("Error", error.name); + } + }; + + // Callback: Recording stopped. + var on_stopped = function(blob) { + that.timer.stop() + + //if the blob is undefined then the user is super clicking or something + if(blob===undefined){ + return; + } + + //if ds recc + var newaudio = { + blob: blob, + dataURI: URL.createObjectURL(blob), + end: new Date(), + isRecording: false, + length: Math.round((that.audio.end - that.audio.start) / 1000), + }; + that.update_audio(newaudio); + + that.deepSpeech2(that.audio.blob, function(response){ + log.debug(response); + if(response.data.result==="success" && response.data.transcript){ + that.gotRecognition(response.data.transcript.trim()); + } else { + notification.alert("Information",that.strings.speechnotrecognized, "OK"); + } + that.update_audio('isRecognizing',false); + }); + + }; + + // Callback: Recorder device got stream - start recording + var on_gotstream= function(stream) { + var newaudio={stream: stream, isRecording: true}; + that.update_audio(newaudio); + + //TO DO - conditionally start timer here (not toggle recording) + //so a device error does not cause timer disaster + // that.timer.reset(); + // that.timer.start(); + + }; + + //If browser rec (Chrome Speech Rec) (and ds is optiona) + if(browserRec.will_work_ok() && ! this.stt_guided){ + //Init browserrec + log.debug("using browser rec"); + log.debug('arh : ' + that.uniqueid); + that.browserrec = browserRec.clone(); + log.debug('arh : ' + that.uniqueid); + that.browserrec.init(that.lang,that.waveHeight,that.uniqueid); + that.usebrowserrec=true; + + //set up events + that.browserrec.onerror = on_error; + that.browserrec.onend = function(){ + //do something here + }; + that.browserrec.onstart = function(){ + //do something here + }; + that.browserrec.onfinalspeechcapture=function(speechtext){ + that.gotRecognition(speechtext); + that.update_audio('isRecording',false); + that.update_audio('isRecognizing',false); + }; + + that.browserrec.oninterimspeechcapture=function(speechtext){ + that.gotInterimRecognition(speechtext); + }; + + //If DS rec + }else { + //set up wav for ds rec + log.debug("using ds rec"); + this.audiohelper = audioHelper.clone(); + this.audiohelper.init(this.waveHeight,this.uniqueid,this); + + that.audiohelper.onError = on_error; + that.audiohelper.onStop = on_stopped; + that.audiohelper.onStream = on_gotstream; + + }//end of setting up recorders + + // Setting up timer. + this.timer = timer.clone(); + this.timer.init(this.maxtime, handle_timer_update); + // Init the timer readout + handle_timer_update(); + }, + + init_strings: function(){ + var that=this; + str.get_strings([ + { "key": "allowmicaccess", "component": 'mod_minilesson'}, + { "key": "nomicdetected", "component": 'mod_minilesson'}, + { "key": "speechnotrecognized", "component": 'mod_minilesson'}, + + ]).done(function (s) { + var i = 0; + that.strings.allowmicaccess = s[i++]; + that.strings.nomicdetected = s[i++]; + that.strings.speechnotrecognized = s[i++]; + }); + }, + + prepare_html: function(){ + this.controls.recordercontainer =$('.audiorec_container_' + this.uniqueid); + this.controls.recorderbutton = $('.' + this.uniqueid + '_recorderdiv'); + this.controls.timerstatus = $('.timerstatus_' + this.uniqueid); + this.passagehash = this.controls.recorderbutton.data('passagehash'); + this.region=this.controls.recorderbutton.data('region'); + this.lang=this.controls.recorderbutton.data('lang'); + this.asrurl=this.controls.recorderbutton.data('asrurl'); + this.maxtime=this.controls.recorderbutton.data('maxtime'); + this.waveHeight=this.controls.recorderbutton.data('waveheight'); + }, + + silence_detected: function(){ + if(this.audio.isRecording){ + this.toggleRecording(); + } + }, + + update_audio: function(newprops,val){ + if (typeof newprops === 'string') { + log.debug('update_audio:' + newprops + ':' + val); + if (this.audio[newprops] !== val) { + this.audio[newprops] = val; + this.audio_updated(); + } + }else{ + for (var theprop in newprops) { + this.audio[theprop] = newprops[theprop]; + log.debug('update_audio:' + theprop + ':' + newprops[theprop]); + } + this.audio_updated(); + } + }, + + register_events: function(){ + var that = this; + this.controls.recordercontainer.click(function(){ + that.toggleRecording(); + }); + + this.audio_updated=function() { + //pointer + if (that.audio.isRecognizing) { + that.show_recorder_pointer('none'); + } else { + that.show_recorder_pointer('auto'); + } + + if(that.audio.isRecognizing || that.audio.isRecording ) { + this.controls.recorderbutton.css('background', '#e52'); + }else{ + this.controls.recorderbutton.css('background', 'green'); + } + + //div content WHEN? + that.controls.recorderbutton.html(that.recordBtnContent()); + }; + + }, + + show_recorder_pointer: function(show){ + if(show) { + this.controls.recorderbutton.css('pointer-events', 'none'); + }else{ + this.controls.recorderbutton.css('pointer-events', 'auto'); + } + + }, + + + gotRecognition:function(transcript){ + log.debug('transcript:' + transcript); + var message={}; + message.type='speech'; + message.capturedspeech = transcript; + //POINT + this.callback(message); + }, + + gotInterimRecognition:function(transcript){ + var message={}; + message.type='interimspeech'; + message.capturedspeech = transcript; + //POINT + this.callback(message); + }, + + cleanWord: function(word) { + return word.replace(/['!"#$%&\\'()\*+,\-\.\/:;<=>?@\[\\\]\^_`{|}~']/g,"").toLowerCase(); + }, + + recordBtnContent: function() { + + if(!this.audio.isRecognizing){ + + if (this.audio.isRecording) { + return ''; + } else { + return ''; + } + + } else { + return ''; + } + }, + toggleRecording: function() { + var that =this; + //If we are recognizing, then we want to discourage super click'ers + if (this.audio.isRecognizing) { + return; + } + + //If we are current recording + if (this.audio.isRecording) { + that.timer.stop(); + + //If using Browser Rec (chrome speech) + if(this.usebrowserrec){ + that.update_audio('isRecording',false); + that.update_audio('isRecognizing',true); + this.browserrec.stop(); + + //If using DS rec + }else{ + this.update_audio('isRecognizing',true); + this.audiohelper.stop(); + } + + //If we are NOT currently recording + } else { + // Run the timer + that.currentTime = 0; + that.timer.reset(); + that.timer.start(); + + + //If using Browser Rec (chrome speech) + if(this.usebrowserrec){ + this.update_audio('isRecording',true); + this.browserrec.start(); + + //If using DS Rec + }else { + var newaudio = { + stream: null, + blob: null, + dataURI: null, + start: new Date(), + end: null, + isRecording: false, + isRecognizing:false, + transcript: null + }; + this.update_audio(newaudio); + this.audiohelper.start(); + } + } + }, + + + deepSpeech2: function(blob, callback) { + var bodyFormData = new FormData(); + var blobname = this.uniqueid + Math.floor(Math.random() * 100) + '.wav'; + bodyFormData.append('audioFile', blob, blobname); + bodyFormData.append('scorer', this.passagehash); + if(this.stt_guided) { + bodyFormData.append('strictmode', 'false'); + }else{ + bodyFormData.append('strictmode', 'true'); + } + //prompt is used by whisper and other transcibers down the line + if(this.currentPrompt!==false){ + bodyFormData.append('prompt', this.currentPrompt); + } + bodyFormData.append('lang', this.lang); + bodyFormData.append('wwwroot', M.cfg.wwwroot); + + var oReq = new XMLHttpRequest(); + oReq.open("POST", this.asrurl, true); + oReq.onUploadProgress= function(progressEvent) {}; + oReq.onload = function(oEvent) { + if (oReq.status === 200) { + callback(JSON.parse(oReq.response)); + } else { + callback({data: {result: "error"}}); + log.debug(oReq.error); + } + }; + try { + oReq.send(bodyFormData); + }catch(err){ + callback({data: {result: "error"}}); + log.debug(err); + } + }, + + };//end of return value + +}); \ No newline at end of file diff --git a/amd/src/browserrec.js b/amd/src/browserrec.js new file mode 100644 index 0000000..bded97a --- /dev/null +++ b/amd/src/browserrec.js @@ -0,0 +1,221 @@ +/* jshint ignore:start */ +define(['jquery', 'core/log'], function ($, log) { + + "use strict"; // jshint ;_; + + log.debug('qtype_aitext browser speech rec: initialising'); + + return { + + recognition: null, + recognizing: false, + final_transcript: '', + interim_transcript: '', + start_timestamp: 0, + lang: 'en-US', + interval: 0, + browsertype: '', + + + //for making multiple instances + clone: function () { + return $.extend(true, {}, this); + }, + + will_work_ok: function(opts){ + //Brave looks like it does speech rec, but it doesnt + var brave = typeof navigator.brave !== 'undefined'; + if(brave){ + this.browsertype = 'brave'; + // return false; + } + + //Edge may or may not work, but its hard to tell from the browser agent + var edge = navigator.userAgent.toLowerCase().indexOf("edg/") > -1; + if(edge && this.browsertype === ''){ + this.browsertype = 'edge'; + //return false; + } + + //Safari may or may not work, but its hard to tell from the browser agent + var has_chrome = navigator.userAgent.indexOf('Chrome') > -1; + var has_safari = navigator.userAgent.indexOf("Safari") > -1; + var safari = has_safari && !has_chrome; + if(safari && this.browsertype === ''){ + this.browsertype = 'safari'; + //return false; + } + + //This is feature detection, and for chrome it can be trusted. + var hasspeechrec = ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window); + if(hasspeechrec && this.browsertype === '' && has_chrome){ + this.browsertype = 'chrome'; + } + + //This is feature detection, and for chrome it can be trusted. + // The others might say they do speech rec, but that does not mean it works + return hasspeechrec; + + }, + + init: function (lang,waveheight,uniqueid) { + log.debug('bh : ' + uniqueid); + var SpeechRecognition = SpeechRecognition || webkitSpeechRecognition; + this.recognition = new SpeechRecognition(); + this.recognition.continuous = true; + this.recognition.interimResults = true; + this.lang = lang; + this.waveHeight = waveheight; + this.uniqueid = uniqueid; + this.prepare_html(); + this.register_events(); + }, + + prepare_html: function(){ + this.canvas =$('.' + this.uniqueid + "_waveform"); + this.canvasCtx = this.canvas[0].getContext("2d"); + }, + + set_grammar: function (grammar) { + var SpeechGrammarList = SpeechGrammarList || webkitSpeechGrammarList; + if (SpeechGrammarList) { + var speechRecognitionList = new SpeechGrammarList(); + speechRecognitionList.addFromString(grammar, 1); + this.recognition.grammars = speechRecognitionList; + } + }, + + start: function () { + var that =this; + + //If we already started ignore this + if (this.recognizing) { + return; + } + this.recognizing = true; + this.final_transcript = ''; + this.interim_transcript = ''; + this.recognition.lang = this.lang;//select_dialect.value; + this.recognition.start(); + this.start_timestamp = Date.now();//event.timeStamp; + that.onstart(); + + + //kick off animation + that.interval = setInterval(function() { + that.drawWave(); + }, 100); + }, + + stop: function () { + var that=this; + this.recognizing = false; + this.recognition.stop(); + clearInterval(this.interval); + this.canvasCtx.clearRect(0, 0, this.canvas.width()*2, this.waveHeight * 2); + setTimeout(function() { + that.onfinalspeechcapture(that.final_transcript); + }, 1000); + this.onend(); + }, + + register_events: function () { + + var recognition = this.recognition; + var that = this; + + recognition.onerror = function (event) { + if (event.error == 'no-speech') { + log.debug('info_no_speech'); + } + if (event.error == 'audio-capture') { + log.debug('info_no_microphone'); + } + if (event.error == 'not-allowed') { + if (event.timeStamp - that.start_timestamp < 100) { + log.debug('info_blocked'); + } else { + log.debug('info_denied'); + } + } + that.onerror({error: {name: event.error}}); + }; + + recognition.onend = function () { + if(that.recognizing){ + that.recognition.start(); + } + + }; + + recognition.onresult = function (event) { + for (var i = event.resultIndex; i < event.results.length; ++i) { + if (event.results[i].isFinal) { + that.final_transcript += event.results[i][0].transcript; + } else { + var provisional_transcript = that.final_transcript + event.results[i][0].transcript; + //the interim and final events do not arrive in sequence, we dont want the length going down, its weird + //so just dont respond when the sequence is wonky + if(provisional_transcript.length < that.interim_transcript.length){ + return; + }else{ + that.interim_transcript = provisional_transcript; + } + that.oninterimspeechcapture(that.interim_transcript); + } + } + + }; + },//end of register events + + drawWave: function() { + + var width = this.canvas.width() * 2; + var bufferLength=4096; + + this.canvasCtx.fillStyle = 'white'; + this.canvasCtx.fillRect(0, 0, width, this.waveHeight*2); + + this.canvasCtx.lineWidth = 5; + this.canvasCtx.strokeStyle = 'gray'; + this.canvasCtx.beginPath(); + + var slicewaveWidth = width / bufferLength; + var x = 0; + + for (var i = 0; i < bufferLength; i++) { + + var v = ((Math.random() * 64) + 96) / 128.0; + var y = v * this.waveHeight; + + if (i === 0) { + // this.canvasCtx.moveTo(x, y); + } else { + this.canvasCtx.lineTo(x, y); + } + x += slicewaveWidth; + } + + this.canvasCtx.lineTo(width, this.waveHeight); + this.canvasCtx.stroke(); + + }, + + onstart: function () { + log.debug('started'); + }, + onerror: function () { + log.debug('error'); + }, + onend: function () { + log.debug('end'); + }, + onfinalspeechcapture: function (speechtext) { + log.debug(speechtext); + }, + oninterimspeechcapture: function (speechtext) { + // log.debug(speechtext); + } + + };//end of returned object +});//total end diff --git a/amd/src/timer.js b/amd/src/timer.js new file mode 100644 index 0000000..efe718b --- /dev/null +++ b/amd/src/timer.js @@ -0,0 +1,111 @@ +/* jshint ignore:start */ +define(['jquery', 'core/log'], function ($, log) { + + "use strict"; // jshint ;_; + + log.debug('Timer: initialising'); + + return { + increment: 1, + initseconds: 0, + seconds: 0, + finalseconds: 0, + intervalhandle: null, + callback: null, + enabled: false, + + //for making multiple instances + clone: function () { + return $.extend(true, {}, this); + }, + + init: function (initseconds, callback) { + this.initseconds = parseInt(initseconds); + this.seconds = parseInt(initseconds); + this.callback = callback; + this.enabled = true; + }, + + start: function () { + if (!this.enabled) { + return; + } + + var self = this; + this.finalseconds = 0; + if (this.initseconds > 0) { + this.increment = -1; + } else { + this.increment = 1; + } + this.intervalhandle = this.customSetInterval(function () { + self.seconds = self.seconds + self.increment; + self.finalseconds = self.finalseconds + 1; + self.callback(); + }, 1000); + }, + + //we use a custom set interval which self adjusts for inaccuracies. + customSetInterval: function (func, time) { + var lastTime = Date.now(), + lastDelay = time, + outp = {}; + + function tick() { + var now = Date.now(), + dTime = now - lastTime; + + lastTime = now; + lastDelay = time + lastDelay - dTime; + outp.id = setTimeout(tick, lastDelay); + func(); + + } + + outp.id = setTimeout(tick, time); + return outp; + }, + + disable: function () { + this.enabled = false; + }, + + enable: function () { + this.enabled = true; + }, + + fetch_display_time: function (someseconds) { + if (!someseconds) { + someseconds = this.seconds; + } + var theHours = '00' + parseInt(someseconds / 3600); + theHours = theHours.substr(theHours.length - 2, 2); + var theMinutes = '00' + parseInt(someseconds / 60); + theMinutes = theMinutes.substr(theMinutes.length - 2, 2); + var theSeconds = '00' + parseInt(someseconds % 60); + theSeconds = theSeconds.substr(theSeconds.length - 2, 2); + var display_time = theHours + ':' + theMinutes + ':' + theSeconds; + return display_time; + }, + + stop: function () { + clearTimeout(this.intervalhandle.id); + }, + + reset: function () { + this.seconds = this.initseconds; + }, + + pause: function () { + this.increment = 0; + }, + resume: function () { + if (this.initseconds > 0) { + this.increment = -1; + } else { + this.increment = 1; + } + } + + };//end of returned object +});//total end diff --git a/amd/src/transcriptmanager.js b/amd/src/transcriptmanager.js new file mode 100644 index 0000000..b9f07bf --- /dev/null +++ b/amd/src/transcriptmanager.js @@ -0,0 +1,80 @@ +define(['jquery', + 'core/log', + 'qtype_aitext/audiorecorder' + ], function($, log, audiorecorder) { + "use strict"; // jshint ;_; + + /* + This file is to manage the quiz stage + */ + + log.debug('qtype_aitext transcriptmanager: initialising'); + + return { + + //a handle on the audio recorder + audiorec: null, + + //for making multiple instances .. for making multiple instances .. for making multiple instances .. multiple.. + clone: function () { + return $.extend(true, {}, this); + }, + + init: function(opts) { + this.register_events(opts); + this.init_components(opts); + }, + + register_events: function(opts) { + var self = this; + $('.retry_' + opts.uniqueid).on('click', function() { + $('.qtype_aitext_audiorecorder_' + opts.uniqueid).removeClass('hidden'); + $('.qtype_aitext_audiosummary_' + opts.uniqueid).addClass('hidden'); + }); + },//end of register events + + init_components: function(opts) { + var app= this; + var theCallback = function(message) { + + switch (message.type) { + case 'recording': + break; + case 'interimspeech': + var wordcount = app.count_words(message.capturedspeech); + $('.' + opts.uniqueid + '_currentwordcount').text(wordcount); + break; + case 'speech': + log.debug("speech at multiaudio"); + var speechtext = message.capturedspeech; + log.debug('speechtext:',speechtext); + + //set speech text to the hidden input + $('.' + opts.uniqueid).val(speechtext); + + //update the wordcount + var wordcount = app.count_words(message.capturedspeech); + $('.' + opts.uniqueid + '_currentwordcount').text(wordcount); + + //hide the recorder and show the summary + $('.qtype_aitext_audiorecorder_' + opts.uniqueid).addClass('hidden'); + $('.qtype_aitext_audiosummary_' + opts.uniqueid).removeClass('hidden'); + + + } //end of switch message type + }; + + //init audio recorder + opts.callback = theCallback; + opts.stt_guided=false; + app.audiorec = audiorecorder.clone(); + app.audiorec.init(opts); + }, //end of init components + + count_words: function(transcript) { + return transcript.trim().split(/\s+/).filter(function(word) { + return word.length > 0; + }).length; + } + }; +}); \ No newline at end of file diff --git a/amd/src/wavencoder.js b/amd/src/wavencoder.js new file mode 100644 index 0000000..47ad2b3 --- /dev/null +++ b/amd/src/wavencoder.js @@ -0,0 +1,90 @@ +define(['jquery', 'core/log'], function ($, log) { + "use strict"; // jshint ;_; + /* + This file is the engine that drives audio rec and canvas drawing. TT Recorder is the just the glory kid + */ + + log.debug('qtype_aitext Wav Encoder initialising'); + + return { + + + //for making multiple instances + clone: function () { + return $.extend(true, {}, this); + }, + + init: function(sampleRate, numChannels) { + this.sampleRate = sampleRate; + this.numChannels = numChannels; + this.numSamples = 0; + this.dataViews = []; + }, + + encode: function(buffer) { + //this would be an event that occurs after recorder has stopped lets just ignore it + if(this.dataViews===undefined){ + return; + } + + var len = buffer[0].length, + nCh = this.numChannels, + view = new DataView(new ArrayBuffer(len * nCh * 2)), + offset = 0; + for (var i = 0; i < len; ++i) { + for (var ch = 0; ch < nCh; ++ch) { + var x = buffer[ch][i] * 0x7fff; + view.setInt16(offset, x < 0 ? Math.max(x, -0x8000) : Math.min(x, 0x7fff), true); + offset += 2; + } + } + this.dataViews.push(view); + this.numSamples += len; + }, + + setString: function(view, offset, str) { + var len = str.length; + for (var i = 0; i < len; ++i) { + view.setUint8(offset + i, str.charCodeAt(i)); + } + }, + + finish: function(mimeType) { + + //this would be an event that occurs after recorder has stopped lets just ignore it + if(this.dataViews===undefined){ + return; + } + + var dataSize = this.numChannels * this.numSamples * 2; + var view = new DataView(new ArrayBuffer(44)); + this.setString(view, 0, 'RIFF'); + view.setUint32(4, 36 + dataSize, true); + this.setString(view, 8, 'WAVE'); + this.setString(view, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, this.numChannels, true); + view.setUint32(24, this.sampleRate, true); + view.setUint32(28, this.sampleRate * 4, true); + view.setUint16(32, this.numChannels * 2, true); + view.setUint16(34, 16, true); + this.setString(view, 36, 'data'); + view.setUint32(40, dataSize, true); + this.dataViews.unshift(view); + var blob = new Blob(this.dataViews, { type: 'audio/wav' }); + this.cleanup(); + return blob; + }, + + cancel: function() { + delete this.dataViews; + }, + + cleanup: function() { + this.cancel(); + } + + };//end of return value + +}); \ No newline at end of file diff --git a/backup/moodle2/backup_qtype_aitext_plugin.class.php b/backup/moodle2/backup_qtype_aitext_plugin.class.php index 827a3e1..c8c02ab 100755 --- a/backup/moodle2/backup_qtype_aitext_plugin.class.php +++ b/backup/moodle2/backup_qtype_aitext_plugin.class.php @@ -22,6 +22,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use qtype_aitext\constants; /** * Provides the information to backup aitext questions @@ -47,10 +48,7 @@ protected function define_question_plugin_structure() { // Now create the qtype own structures. - $aitext = new backup_nested_element('aitext', ['id'], [ - 'aiprompt', 'markscheme', 'sampleanswer', 'responseformat', 'responsefieldlines', 'minwordlimit', 'maxwordlimit', - 'graderinfo', 'graderinfoformat', 'responsetemplate', 'model', - 'responsetemplateformat', 'maxbytes']); + $aitext = new backup_nested_element('aitext', ['id'], constants::EXTRA_FIELDS); // Now the own qtype tree. $pluginwrapper->add_child($aitext); diff --git a/classes/constants.php b/classes/constants.php new file mode 100644 index 0000000..d295013 --- /dev/null +++ b/classes/constants.php @@ -0,0 +1,121 @@ +. + +namespace qtype_aitext; + +/** + * Constants class for the aitext question type. + * + * @package qtype + * @subpackage aitext + * @copyright 2024 Justin Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +class constants { + + const RELEVANCE_NONE = 0; + const RELEVANCE_QTEXT = 1; + const RELEVANCE_COMPARISON = 2; + const LANGUAGES = ['ar-ae', 'ar-sa', 'eu-es', 'bg-bg', 'hr-hr', 'zh-cn', 'cs-cz', 'da-dk', 'nl-nl', 'nl-be', 'en-us', 'en-gb', + 'en-au', 'en-nz', 'en-za', 'en-in', 'en-ie', 'en-wl', 'en-ab', 'fa-ir', 'fil-ph', 'fi-fi', 'fr-ca', 'fr-fr', 'de-de', + 'de-at', 'de-ch', 'hi-in', 'el-gr', 'he-il', 'hu-hu', 'id-id', 'is-is', 'it-it', 'ja-jp', 'ko-kr', 'lt-lt', 'lv-lv', + 'mi-nz', 'ms-my', 'mk-mk', 'no-no', 'pl-pl', 'pt-br', 'pt-pt', 'ro-ro', 'ru-ru', 'es-us', 'es-es', 'sk-sk', 'sl-si', + 'sr-rs', 'sv-se', 'ta-in', 'te-in', 'tr-tr', 'uk-ua', 'vi-vn']; + + const RESPONSE_FORMATS = ['plain', 'editor', 'monospaced', 'audio']; + const EXTRA_FIELDS = ['responseformat', + 'responsefieldlines', + 'minwordlimit', + 'maxwordlimit', + 'graderinfo', + 'graderinfoformat', + 'responsetemplate', + 'responsetemplateformat', + 'maxbytes', + 'aiprompt', + 'markscheme', + 'sampleanswer', + 'model', + 'maxtime', + 'responselanguage', + 'feedbacklanguage', + 'relevance', + 'relevanceanswer', + ]; + + /** + * The different response languages that the question type supports. + * internal name => human-readable name. + * + * @return array + */ + public static function get_languages($includeauto=false) { + $responselanguages = []; + foreach (self::LANGUAGES as $langcode) { + $responselanguages[$langcode] = get_string($langcode, "qtype_aitext"); + } + if($includeauto){ + $responselanguages['currentlanguage'] = get_string('currentlanguage', "qtype_aitext"); + } + return $responselanguages; + } + + /** + * The different response formats that the question type supports. + * internal name => human-readable name. + * + * @return array + */ + public static function get_response_formats() { + $responseformats = []; + foreach (self::RESPONSE_FORMATS as $theformat) { + $responseformats[$theformat] = get_string('format' . $theformat, "qtype_aitext"); + } + return $responseformats; + } + + /** + * The time limits for audio recording + * no. of seconds => human-readable name (min /secs) + * + * @return array + */ + public static function get_time_limits() { + $opts = [ + 0 => get_string("notimelimit", "qtype_aitext"), + 30 => get_string("xsecs", "qtype_aitext", '30'), + 45 => get_string("xsecs", "qtype_aitext", '45'), + 60 => get_string("onemin", "qtype_aitext"), + 90 => get_string("oneminxsecs", "qtype_aitext", '30'), + ]; + for ($x = 2; $x <= 30; $x++) { + $opts[$x * 60] = get_string("xmins", "qtype_aitext", $x); + $opts[($x * 60) + 30] = get_string("xminsecs", "qtype_aitext", ['minutes' => $x, 'seconds' => 30]); + } + return $opts; + } + + public static function get_relevance_opts() { + $opts = [ + self::RELEVANCE_NONE => get_string("relevance_none", "qtype_aitext"), + self::RELEVANCE_QTEXT => get_string("relevancetoqtext", "qtype_aitext"), + self::RELEVANCE_COMPARISON => get_string("relevancetocomparison", "qtype_aitext"), + ]; + return $opts; + } + +} diff --git a/classes/external.php b/classes/external.php index b551998..d7e78b7 100644 --- a/classes/external.php +++ b/classes/external.php @@ -58,7 +58,7 @@ public static function fetch_ai_grade_parameters(): external_function_parameters * @param integer $defaultmark * @param string $prompt * @param string $marksscheme - * @return void + * @return array */ public static function fetch_ai_grade($response, $defaultmark, $prompt, $marksscheme) { // Get our AI helper. diff --git a/db/install.xml b/db/install.xml index 9b43000..c2742a1 100755 --- a/db/install.xml +++ b/db/install.xml @@ -21,6 +21,12 @@ + + + + + + diff --git a/db/upgrade.php b/db/upgrade.php index 6468b74..4809101 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -62,5 +62,50 @@ function xmldb_qtype_aitext_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2024051100, 'qtype', 'aitext'); } + if ($oldversion < 2024051102) { + + // Define field model to be added to qtype_aitext. + $table = new xmldb_table('qtype_aitext'); + $fields = []; + $fields[] = new xmldb_field('responselanguage', XMLDB_TYPE_CHAR, '16', null, XMLDB_NOTNULL, null, 'en-us'); + $fields[] = new xmldb_field('feedbacklanguage', XMLDB_TYPE_CHAR, '16', null, XMLDB_NOTNULL, null, 'en-us'); + $fields[] = new xmldb_field('maxtime', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $fields[] = new xmldb_field('relevance', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $fields[] = new xmldb_field('relevanceanswer', XMLDB_TYPE_TEXT, 'small', null, null, null, null); + + // Conditionally add fields + foreach($fields as $field) { + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + } + + // Aitext savepoint reached. + upgrade_plugin_savepoint(true, 2024051102, 'qtype', 'aitext'); + } + + if ($oldversion < 2024051103) { + // JSON prompt upgrade to factor in relevance. If the user has edited the JSON prompt, we don't touch it. + $originaljsonprompt = 'Return only a JSON object which enumerates a set of 2 elements.'; + $originaljsonprompt .= 'The JSON object should be in this format: {feedback":"string","marks":"number"}'; + $originaljsonprompt .= ' where marks is a single number summing all marks.'; + $originaljsonprompt .= ' Also show the marks as part of the feedback.'; + $originaljsonprompt = preg_replace('/\s+/', ' ', trim($originaljsonprompt)); + + $currentjsonprompt = get_config('qtype_aitext', 'jsonprompt'); + $currentjsonprompt = preg_replace('/\s+/', ' ', trim($currentjsonprompt)); + + if ($currentjsonprompt == $originaljsonprompt || true) { + $newprompt = "Return only a JSON object which enumerates a set of 4 elements."; + $newprompt .= ' The JSON object should be in this format: {"feedback":"string","correctedtext":"string",marks":"number", "relevance": "number"}'; + $newprompt .= " where marks is a single number summing all marks."; + set_config('jsonprompt', $newprompt, 'qtype_aitext'); + } + + + // Aitext savepoint reached. + upgrade_plugin_savepoint(true, 2024051103, 'qtype', 'aitext'); + } + return true; } diff --git a/edit_aitext_form.php b/edit_aitext_form.php index 65214c2..e70244c 100755 --- a/edit_aitext_form.php +++ b/edit_aitext_form.php @@ -23,6 +23,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use qtype_aitext\constants; + /** * aitext question type editing form. * @@ -87,13 +89,21 @@ protected function definition_inner($mform) { $mform->setExpanded('responseoptions'); $mform->addElement('select', 'responseformat', - get_string('responseformat', 'qtype_aitext'), $qtype->response_formats()); + get_string('responseformat', 'qtype_aitext'), constants::get_response_formats()); $mform->setDefault('responseformat', get_config('qtype_aitext', 'responseformat')); + $mform->addElement('select', 'responselanguage', + get_string('responselanguage', 'qtype_aitext'), constants::get_languages()); + $mform->setDefault('responselanguage', get_config('qtype_aitext', 'responselanguage')); + + $mform->addElement('select', 'feedbacklanguage', + get_string('feedbacklanguage', 'qtype_aitext'), constants::get_languages(true)); + $mform->setDefault('feedbacklanguage', get_config('qtype_aitext', 'feedbacklanguage')); + $mform->addElement('select', 'responsefieldlines', get_string('responsefieldlines', 'qtype_aitext'), $qtype->response_sizes()); $mform->setDefault('responsefieldlines', $this->get_default_value('responsefieldlines', 10)); - $mform->hideIf('responsefieldlines', 'responseformat', 'eq', 'noinline'); + $mform->hideIf('responsefieldlines', 'responseformat', 'eq', 'audio'); // Create a text box that can be enabled/disabled for max/min word limits options. $wordlimitoptions = ['size' => '6', 'maxlength' => '6']; @@ -104,7 +114,7 @@ protected function definition_inner($mform) { $mform->addGroup($mingrp, 'mingroup', get_string('minwordlimit', 'qtype_aitext'), ' ', false); $mform->addHelpButton('mingroup', 'minwordlimit', 'qtype_aitext'); $mform->disabledIf('minwordlimit', 'minwordenabled', 'notchecked'); - $mform->hideIf('mingroup', 'responseformat', 'eq', 'noinline'); + $mform->hideIf('mingroup', 'responseformat', 'eq', 'audio'); $maxgrp[] = $mform->createElement('text', 'maxwordlimit', '', $wordlimitoptions); $mform->setType('maxwordlimit', PARAM_INT); @@ -113,7 +123,21 @@ protected function definition_inner($mform) { $mform->addGroup($maxgrp, 'maxgroup', get_string('maxwordlimit', 'qtype_aitext'), ' ', false); $mform->addHelpButton('maxgroup', 'maxwordlimit', 'qtype_aitext'); $mform->disabledIf('maxwordlimit', 'maxwordenabled', 'notchecked'); - $mform->hideIf('maxgroup', 'responseformat', 'eq', 'noinline'); + $mform->hideIf('maxgroup', 'responseformat', 'eq', 'audio'); + + // timelimit + $mform->addElement('select', 'maxtime', get_string('maxtime', 'qtype_aitext'), constants::get_time_limits()); + $mform->setDefault('maxtime', get_config('qtype_aitext', 'maxtime')); + $mform->hideIf('maxtime', 'responseformat', 'neq', 'audio'); + + // Relevance + $mform->addElement('header', 'relevanceheader', get_string('relevanceheader', 'qtype_aitext')); + $mform->addElement('select', 'relevance', get_string('relevance', 'qtype_aitext'), constants::get_relevance_opts()); + $mform->setDefault('relevance', get_config('qtype_aitext', 'relevance')); + // Relevance answer + $mform->addElement('textarea', 'relevanceanswer', get_string('relevanceanswer', 'qtype_aitext'), + ['maxlen' => 50, 'rows' => 6, 'size' => 30]); + $mform->hideIf('relevanceanswer', 'relevance', 'neq', constants::RELEVANCE_COMPARISON); $mform->addElement('header', 'responsetemplateheader', get_string('responsetemplateheader', 'qtype_aitext')); $mform->addElement('editor', 'responsetemplate', get_string('responsetemplate', 'qtype_aitext'), @@ -142,18 +166,24 @@ protected function data_preprocessing($question) { return $question; } - $question->responseformat = $question->options->responseformat; - $question->responsefieldlines = $question->options->responsefieldlines; - $question->minwordenabled = $question->options->minwordlimit ? 1 : 0; - $question->minwordlimit = $question->options->minwordlimit; - $question->maxwordenabled = $question->options->maxwordlimit ? 1 : 0; - $question->maxwordlimit = $question->options->maxwordlimit; - $question->aiprompt = $question->options->aiprompt; - - $question->responsetemplate = [ - 'text' => $question->options->responsetemplate, - 'format' => $question->options->responsetemplateformat, - ]; + foreach (constants::EXTRA_FIELDS as $field) { + switch ($field) { + case 'minwordenabled': + $question->minwordenabled = $question->options->minwordlimit ? 1 : 0; + break; + case 'maxwordenabled': + $question->maxwordenabled = $question->options->maxwordlimit ? 1 : 0; + break; + case 'responsetemplate': + $question->responsetemplate = [ + 'text' => $question->options->responsetemplate, + 'format' => $question->options->responsetemplateformat, + ]; + break; + default: + $question->{$field} = $question->options->{$field}; + } + } return $question; } diff --git a/lang/en/qtype_aitext.php b/lang/en/qtype_aitext.php index 9f3a069..1cb41d1 100755 --- a/lang/en/qtype_aitext.php +++ b/lang/en/qtype_aitext.php @@ -30,7 +30,10 @@ $string['aipromptmissing'] = 'The ai prompt is missing. Please enter a prompt on the basis of which the feedback is generated.'; $string['answerfiles'] = 'Answer files'; $string['answertext'] = 'Answer text'; +$string['answeraudio'] = 'Answer audio'; $string['attachmentsoptional'] = 'Attachments are optional'; +$string['batchmode'] = 'Batch mode'; +$string['batchmode_setting'] = 'Requests to the external LLM will be queued'; $string['cachedef_stringdata'] = 'Cachedef stringdata'; $string['defaultmarksscheme'] = 'Marks scheme'; $string['defaultmarksscheme_setting'] = 'This will be the default marks scheme for new questions. Questions authors should alter this to suit the question.'; @@ -43,6 +46,9 @@ $string['err_maxwordlimitnegative'] = 'Maximum word limit cannot be a negative number'; $string['err_minwordlimit'] = 'Minimum word limit is enabled but is not set'; $string['err_minwordlimitnegative'] = 'Minimum word limit cannot be a negative number'; +$string['feedbacklanguage'] = 'Feedback language'; +$string['feedbacklanguage_setting'] = 'The feedback language that we instruct AI to use'; +$string['formataudio'] = 'Audio Recorder'; $string['formateditor'] = 'HTML editor'; $string['formateditorfilepicker'] = 'HTML editor with file picker'; $string['formatmonospaced'] = 'Plain text, monospaced font'; @@ -51,7 +57,7 @@ $string['get_llmmfeedback'] = 'Get LLM feedback'; $string['graderinfo'] = 'Information for graders'; $string['graderinfoheader'] = 'Grader information'; -$string['jsonprompt'] = 'JSon prompt'; +$string['jsonprompt'] = 'JSON prompt'; $string['jsonprompt_setting'] = 'Instructions sent to convert the returned value into json'; $string['markscheme'] = 'Mark scheme'; $string['markscheme_help'] = 'This will tell the AI grader how to give a numerical grade to the student response. The total possible score is this question\'s \'Default mark\''; @@ -69,12 +75,9 @@ $string['pluginnameadding'] = 'Adding an AI Text question'; $string['pluginnameediting'] = 'Editing an AI Text question'; $string['pluginnamesummary'] = 'Allows a response of a file upload and/or online text. The student response is processed by the configured AI/Large language model which returns feedback and optionally a grade..'; -$string['privacy::responsefieldlines'] = 'Number of lines indicating the size of the input box (textarea).'; -$string['privacy:metadata'] = 'AI Text question type plugin allows question authors to set default options as user preferences.'; $string['privacy:preference:attachments'] = 'Number of allowed attachments.'; $string['privacy:preference:attachmentsrequired'] = 'Number of required attachments.'; $string['privacy:preference:defaultmark'] = 'The default mark set for a given question.'; -$string['privacy:preference:disclaimer'] = 'Text to indicate the feedback and/or marking is from a LLM'; $string['privacy:preference:maxbytes'] = 'Maximum file size.'; $string['privacy:preference:responseformat'] = 'What is the response format (HTML editor, plain text, etc.)?'; $string['prompt'] = 'Prompt'; @@ -83,9 +86,8 @@ $string['responsefieldlines'] = 'Input box size'; $string['responseformat'] = 'Response format'; $string['responseformat_setting'] = 'The editor the student uses when responding'; -$string['responseoptions'] = 'Response options'; -$string['responsenotrequired'] = 'Text input is optional'; -$string['responseisrequired'] = 'Require the student to enter text'; +$string['responselanguage'] = 'Response language'; +$string['responselanguage_setting'] = 'The response language we are expecting'; $string['responsenotrequired'] = 'Text input is optional'; $string['responseoptions'] = 'Response options'; $string['responsetemplate'] = 'Response template'; @@ -96,9 +98,98 @@ $string['sampleanswerempty'] = 'Make sure that you have an AI prompt and sample answer before testing.'; $string['sampleanswerevaluate'] = 'Evaluate Sample Answer'; $string['showprompt'] = 'Show prompt'; -$string['thedefaultmarksscheme'] = 'Deduct a point from the total score for each grammar or spelling mistake.'; +$string['thedefaultmarksscheme'] = 'Deduct a point from the maximum score for each grammar or spelling mistake.'; $string['thedefaultprompt'] = 'Explain if there is anything wrong with the grammar and spelling in the text.'; -$string['untestedquestionbehaviour'] = 'Untested question behaviour'; +$string['usecoreai'] = 'Use core ai'; +$string['usecoreai_setting'] = 'If you are using Moodle 4.5 or above you can use the core ai subsystem. Otherwise you will need to have tool_aiconnect installed.'; $string['wordcount'] = 'Word count: {$a}'; $string['wordcounttoofew'] = 'Word count: {$a->count}, less than the required {$a->limit} words.'; $string['wordcounttoomuch'] = 'Word count: {$a->count}, more than the limit of {$a->limit} words.'; + + +$string['currentlanguage'] = 'Current language'; +$string['en-us'] = 'English (US)'; +$string['es-us'] = 'Spanish (US)'; +$string['en-au'] = 'English (Aus.)'; +$string['en-nz'] = 'English (NZ)'; +$string['en-za'] = 'English (S.Africa)'; +$string['en-gb'] = 'English (GB)'; +$string['fr-ca'] = 'French (Can.)'; +$string['fr-fr'] = 'French (FR)'; +$string['it-it'] = 'Italian (IT)'; +$string['pt-br'] = 'Portuguese (BR)'; +$string['en-in'] = 'English (IN)'; +$string['es-es'] = 'Spanish (ES)'; +$string['fr-fr'] = 'French (FR)'; +$string['fil-ph'] = 'Filipino'; +$string['de-de'] = 'German (DE)'; +$string['de-ch'] = 'German (CH)'; +$string['de-at'] = 'German (AT)'; +$string['da-dk'] = 'Danish (DK)'; +$string['hi-in'] = 'Hindi'; +$string['ko-kr'] = 'Korean'; +$string['ar-ae'] = 'Arabic (Gulf)'; +$string['ar-sa'] = 'Arabic (Modern Standard)'; +$string['zh-cn'] = 'Chinese (Mandarin-Mainland)'; +$string['nl-nl'] = 'Dutch (NL)'; +$string['nl-be'] = 'Dutch (BE)'; +$string['en-ie'] = 'English (Ireland)'; +$string['en-wl'] = 'English (Wales)'; +$string['en-ab'] = 'English (Scotland)'; +$string['fa-ir'] = 'Farsi'; +$string['he-il'] = 'Hebrew'; +$string['id-id'] = 'Indonesian'; +$string['ja-jp'] = 'Japanese'; +$string['ms-my'] = 'Malay'; +$string['pt-pt'] = 'Portuguese (PT)'; +$string['ru-ru'] = 'Russian'; +$string['ta-in'] = 'Tamil'; +$string['te-in'] = 'Telugu'; +$string['tr-tr'] = 'Turkish'; +$string['uk-ua'] = 'Ukranian'; +$string['eu-es'] = 'Basque'; +$string['fi-fi'] = 'Finnish'; +$string['hu-hu'] = 'Hungarian'; +$string['sv-se'] = 'Swedish'; +$string['no-no'] = 'Norwegian'; +$string['nb-no'] = 'Norwegian (Bokmål)'; +$string['nn-no'] = 'Norwegian (Nynorsk)'; +$string['pl-pl'] = 'Polish'; +$string['ro-ro'] = 'Romanian'; +$string['ro-ro'] = 'Romanian'; +$string['mi-nz'] = 'Maori'; +$string['bg-bg'] = 'Bulgarian'; +$string['cs-cz'] = 'Czech'; +$string['el-gr'] = 'Greek'; +$string['hr-hr'] = 'Croatian'; +$string['hu-hu'] = 'Hungarian'; +$string['lt-lt'] = 'Lithuanian'; +$string['lv-lv'] = 'Latvian'; +$string['sk-sk'] = 'Slovak'; +$string['sl-si'] = 'Slovenian'; +$string['is-is'] = 'Icelandic'; +$string['mk-mk'] = 'Macedonian'; +$string['no-no'] = 'Norwegian'; +$string['sr-rs'] = 'Serbian'; +$string['vi-vn'] = 'Vietnamese'; +$string['relevance'] = 'Relevance'; +$string['relevanceanswer'] = 'Relevance Comparison Answer'; +$string['relevance_setting'] = 'How to determine the relevance of the response to the question asked.'; +$string['relevance_comparison'] = 'Relevance Comparison Answer'; +$string['relevance_none'] = 'Relevance not considered'; +$string['relevancetoqtext'] = 'Relevance to question'; +$string['relevancetocomparison'] = 'Relevance to comparison answer'; +$string['maxtime'] = 'Time limit'; +$string['maxtime_setting'] = 'This is the default time limit for audio recording responses.'; +$string['notimelimit'] = 'No time limit'; +$string['xsecs'] = '{$a} seconds'; +$string['onemin'] = '1 minute'; +$string['xmins'] = '{$a} minutes'; +$string['oneminxsecs'] = '1 minutes {$a} seconds'; +$string['xminsecs'] = '{$a->minutes} minutes {$a->seconds} seconds'; +$string['questionanswered'] = 'Question Answered'; +$string['retry'] = 'Retry'; +$string['currentwordcount'] = 'Total Words'; +$string['submissionrelevance'] = 'Relevance: {$a}%'; +$string['relevanceheader'] = 'Relevance'; +$string['correctedtext'] = 'Corrections:'; \ No newline at end of file diff --git a/question.php b/question.php index 14b3600..46cc397 100755 --- a/question.php +++ b/question.php @@ -26,6 +26,8 @@ defined('MOODLE_INTERNAL') || die(); +use qtype_aitext\constants; + require_once($CFG->dirroot . '/question/type/questionbase.php'); use tool_aiconnect\ai; /** @@ -42,6 +44,14 @@ class qtype_aitext_question extends question_graded_automatically_with_countback */ public $responseformat; + + /** + * LLM Model, will vary between AI systems, e.g. gpt4 or llama3 + + * @var mixed $model Store the llm model used for the question. + */ + public $model; + /** * Count of lines of text * @@ -55,12 +65,6 @@ class qtype_aitext_question extends question_graded_automatically_with_countback /** @var int indicates whether the maximum number of words required */ public $maxwordlimit; - /** - * LLM Model, will vary between AI systems, e.g. gpt4 or llama3 - * @var stream_set_blocking - */ - public $model; - /** * used in the question editing interface @@ -145,34 +149,89 @@ public function apply_attempt_state(question_attempt_step $step) { * large language model such as ChatGPT * * @param array $response - * @return void + * @return array An array containing the grade fraction and the question state. + * */ public function grade_response(array $response): array { + global $DB; if (!$this->is_complete_response($response)) { - $grade = [0 => 0, question_state::$needsgrading]; - return $grade; + return [0 => 0, question_state::$needsgrading]; } - $ai = new ai\ai($this->model); - if (is_array($response)) { + if (get_config('qtype_aitext', 'usecoreai')) { $fullaiprompt = $this->build_full_ai_prompt($response['answer'], $this->aiprompt, + - $this->defaultmark, $this->markscheme); + + global $USER; + $contextid = 1; + $action = new \core_ai\aiactions\summarise_text( + contextid: $contextid, + userid: $USER->id, + prompttext: $fullaiprompt, + ); + $manager = new \core_ai\manager(); + $result = $manager->process_action($action); + $data = (object) $result->get_response_data(); + $contentobject = json_decode($data->generatedcontent); + + } else { + $ai = new ai\ai($this->model); + if (get_config('qtype_aitext', 'batchmode')) { + $this->queue_ai_processing($response['answer'], $this->aiprompt, $this->defaultmark, $this->markscheme); + return [0 => 0, question_state::$needsgrading]; + } + if (is_array($response)) { + $fullaiprompt = $this->build_full_ai_prompt($response['answer'], $this->aiprompt, $this->defaultmark, $this->markscheme); - $llmresponse = $ai->prompt_completion($fullaiprompt); - $feedback = $llmresponse['response']['choices'][0]['message']['content']; - } + $llmresponse = $ai->prompt_completion($fullaiprompt); + $feedback = $llmresponse['response']['choices'][0]['message']['content']; + } - $contentobject = $this->process_feedback($feedback); + $contentobject = $this->process_feedback($feedback); + } // If there are no marks, write the feedback and set to needs grading . if (is_null($contentobject->marks)) { $grade = [0 => 0, question_state::$needsgrading]; } else { - $fraction = $contentobject->marks / $this->defaultmark; + // Calculate the fraction of the marks but AI sometimes gives more marks than possible, so cap it. + $fraction = $contentobject->marks > $this->defaultmark ? 1 : $contentobject->marks / $this->defaultmark; + + // Relevance penalty. + if (isset($contentobject->relevance) && $contentobject->relevance !== null) { + $fraction = $fraction * ($contentobject->relevance * 0.01); + } + $grade = [$fraction, question_state::graded_state_for_fraction($fraction)]; } $this->insert_feedback_and_prompt($fullaiprompt, $contentobject); return $grade; } + /** + * Queues the AI processing in batch mode. + * + * @param string $answer The student's answer. + * @param string $aiprompt The AI prompt. + * @param float $defaultmark The default mark. + * @param string $markscheme The mark scheme. + * @package qtype_aitext + */ + private function queue_ai_processing(string $answer, string $aiprompt, float $defaultmark, string $markscheme): void { + global $DB; + $data = [ + 'activity' => 'qtype_aitext', + 'status' => 0, + 'tries' => 0, + 'prompttext' => $this->build_full_ai_prompt($answer, $aiprompt, $defaultmark, $markscheme), + 'actiondata' => $this->step->get_id(), + 'timecreated' => time(), + 'timemodified' => time(), + + ]; + + $DB->insert_record('tool_aiconnect_queue', $data); + } + /** * Inserts the AI feedback and prompt into the attempt step data. * @@ -203,24 +262,53 @@ public function insert_feedback_and_prompt($fullaiprompt, $contentobject): void * @return string; */ public function build_full_ai_prompt($response, $aiprompt, $defaultmark, $markscheme): string { - $responsetext = strip_tags($response); - $responsetext = '[['.$responsetext.']]'; - $prompt = get_config('qtype_aitext', 'prompt'); - $prompt = preg_replace("/\[responsetext\]/", $responsetext, $prompt); - $prompt .= ' '.trim($aiprompt); - - if ($markscheme > '') { - // Tell the LLM how to mark the submission. - $prompt .= " The total score is: $defaultmark ."; - $prompt .= ' '.$markscheme; + $prompttemplate = "You are evaluating a student's {{responselanguage}} language response to a question. "; + $prompttemplate .= " {{jsonprompt}}. "; + // $prompttemplate .= " Return only a JSON object which enumerates a set of 3 elements.The JSON object should be in this format: {"feedback":"string","marks":"number", "relevance": "number"} where marks is a single number summing all marks. "; + + $prompttemplate .= get_config('qtype_aitext', 'prompt'); + // $prompttemplate = "In [{{responsetext}}] analyse but do not mention the part between [[ and ]] as follows: "; + + $prompttemplate .= " {{{aiprompt}}}"; + // $prompttemplate .= " Explain if there is anything wrong with the grammar and spelling in the text"; + + if (!empty($markscheme)) { + $prompttemplate .= " Set marks in the json object according to the following criteria: {The maximum score is {{maximumscore}}. {{markscheme}}}"; + // $prompttemplate .=" Set marks in the json object according to the following criteria: {The total score is 5. Deduct a point from the maximum score for each grammar or spelling mistake.}" } else { - // Todo should this be a plugin setting value?. - $prompt .= ' Set marks to null in the json object.'.PHP_EOL; + $prompttemplate .= " Set marks to null in the json object."; } - $prompt .= ' '.trim(get_config('qtype_aitext', 'jsonprompt')); - $prompt .= ' translate the feedback to the language '.current_language(); + switch($this->relevance){ + case constants::RELEVANCE_QTEXT: + $prompttemplate .= " Calculate the relevance of the answer (percentage) to the following question : {{{questiontext}}}"; + break; + case constants::RELEVANCE_COMPARISON: + $prompttemplate .= " Calculate the relevance of the answer (percentage) to the extent it contains similar concepts to the following model answer : {{{relevanceanswer}}}"; + break; + case constants::RELEVANCE_NONE: + default: + $prompttemplate .= " Set relevance to null in the json object."; + break; + } + $prompttemplate .= " Translate the feedback to the language: {{feedbacklanguage}}."; + + // set up the parameters to merge with the prompt template + $responselanguage = empty($this->responselanguage) ? 'en-us' : $this->responselanguage; + $responselanguagename = get_string($responselanguage, 'qtype_aitext'); + $params = [ + '[responsetext]' => '[[' . strip_tags($response) . ']]', + '{{aiprompt}}' => trim($aiprompt), + '{{maximumscore}}' => $defaultmark, + '{{markscheme}}' => $markscheme, + '{{jsonprompt}}' => trim(get_config('qtype_aitext', 'jsonprompt')), + '{{relevanceanswer}}' => $this->relevanceanswer, + '{{questiontext}}' => strip_tags($this->questiontext), + '{{feedbacklanguage}}' => $this->feedbacklanguage == 'currentlanguage' || empty($this->feedbacklanguage) ? + current_language() : $this->feedbacklanguage, + '{{responselanguage}}' => $responselanguagename, + ]; + $prompt = strtr($prompttemplate, $params); return $prompt; - } /** * @@ -238,10 +326,20 @@ public function process_feedback(string $feedback) { $contentobject = json_decode($feedback); if (json_last_error() === JSON_ERROR_NONE) { $contentobject->feedback = trim($contentobject->feedback); - $contentobject->feedback = preg_replace(['/\[\[/', '/\]\]/'], '"', $contentobject->feedback); + $contentobject->feedback = preg_replace(['/\[\[/', '/\]\]/'], '"', + $contentobject->feedback); + // Relevance. + if (isset($contentobject->relevance) && $contentobject->relevance !== null) { + $contentobject->feedback .= ' ' . get_string('submissionrelevance', 'qtype_aitext', $contentobject->relevance); + } + // Corrections. + if (isset($contentobject->correctedtext) && !empty($contentobject->correctedtext)) { + $contentobject->feedback .= '
' . get_string('correctedtext', 'qtype_aitext') . '
'; + $contentobject->feedback .= $contentobject->correctedtext; + } $disclaimer = get_config('qtype_aitext', 'disclaimer'); $disclaimer = str_replace("[[model]]", $this->model, $disclaimer); - $contentobject->feedback .= ' '.$this->llm_translate($disclaimer); + $contentobject->feedback .= '
' . $this->llm_translate($disclaimer); } else { $contentobject = (object) [ "feedback" => $feedback, @@ -319,9 +417,12 @@ public function get_expected_data() { */ public function summarise_response(array $response) { $output = null; - if (isset($response['answer'])) { - $output .= question_utils::to_plain_text($response['answer'], + if (isset($response['answer']) && isset($response['answerformat'])) { + $output = question_utils::to_plain_text($response['answer'], $response['answerformat'], ['para' => false]); + } else if (isset($response['answer'])) { + $output = question_utils::to_plain_text($response['answer'], + FORMAT_HTML, ['para' => false]); } return $output; @@ -474,12 +575,15 @@ public function get_question_definition_for_external_rendering(question_attempt // ideally, we should return as much as settings as possible (depending on the state and display options). $settings = [ + 'feedbacklanguage' => $this->feedbacklanguage, + 'responselanguage' => $this->responselanguage, 'responseformat' => $this->responseformat, 'responsefieldlines' => $this->responsefieldlines, 'responsetemplate' => $this->responsetemplate, 'responsetemplateformat' => $this->responsetemplateformat, 'minwordlimit' => $this->minwordlimit, 'maxwordlimit' => $this->maxwordlimit, + 'maxtime' => $this->maxtime, ]; return $settings; diff --git a/questiontype.php b/questiontype.php index ec48161..f1c6064 100755 --- a/questiontype.php +++ b/questiontype.php @@ -26,6 +26,8 @@ defined('MOODLE_INTERNAL') || die(); +use qtype_aitext\constants; + require_once($CFG->libdir . '/questionlib.php'); /** @@ -124,6 +126,14 @@ public function save_question_options($formdata) { $options->responsetemplate = $formdata->responsetemplate['text']; $options->responsetemplateformat = $formdata->responsetemplate['format']; + // Audio recording related options. + $options->responselanguage = $formdata->responselanguage; + $options->feedbacklanguage = $formdata->feedbacklanguage; + $options->maxtime = $formdata->maxtime; + $options->relevance = $formdata->relevance; + $options->relevanceanswer = isset($formdata->relevanceanswer) ? $formdata->relevanceanswer : null; + + $DB->update_record('qtype_aitext', $options); } /** @@ -136,23 +146,13 @@ public function save_question_options($formdata) { protected function initialise_question_instance(question_definition $question, $questiondata) { parent::initialise_question_instance($question, $questiondata); /**@var qtype_aitext_question $question */ - $question->responseformat = $questiondata->options->responseformat; - $question->responsefieldlines = $questiondata->options->responsefieldlines; - $question->minwordlimit = $questiondata->options->minwordlimit; - $question->maxwordlimit = $questiondata->options->maxwordlimit; - $question->graderinfo = $questiondata->options->graderinfo; - $question->graderinfoformat = $questiondata->options->graderinfoformat; - $question->responsetemplate = $questiondata->options->responsetemplate; - $question->responsetemplateformat = $questiondata->options->responsetemplateformat; - $question->aiprompt = $questiondata->options->aiprompt; - $question->markscheme = $questiondata->options->markscheme; - $question->sampleanswer = $questiondata->options->sampleanswer; - /* Legacy quesitons may not have a model set, so assign the first in the settings */ - if (empty($question->model)) { + foreach (constants::EXTRA_FIELDS as $field) { + $question->{$field} = $questiondata->options->{$field}; + } + /* Legacy questions may not have a model set, so assign the first in the settings */ + if (empty($questiondata->options->model)) { $model = explode(",", get_config('tool_aiconnect', 'model'))[0]; $question->model = $model; - } else { - $question->model = $questiondata->options->model; } } /** @@ -170,21 +170,7 @@ public function delete_question($questionid, $contextid) { } /** - * The different response formats that the question type supports. - * internal name => human-readable name. - * - * @return array - */ - public function response_formats() { - return [ - 'editor' => get_string('formateditor', 'qtype_aitext'), - 'plain' => get_string('formatplain', 'qtype_aitext'), - 'monospaced' => get_string('formatmonospaced', 'qtype_aitext'), - ]; - } - - /** - * The choices that should be offerd when asking if a response is required + * The choices that should be offered when asking if a response is required * * @return array */ @@ -247,21 +233,7 @@ public function attachments_required_options() { * @return array */ public function extra_question_fields() { - return [ - 'qtype_aitext', - 'responseformat', - 'responsefieldlines', - 'minwordlimit', - 'maxwordlimit', - 'graderinfo', - 'graderinfoformat', - 'responsetemplate', - 'responsetemplateformat', - 'aiprompt', - 'markscheme', - 'sampleanswer', - 'model', - ]; + return ['qtype_aitext'] + constants::EXTRA_FIELDS; } /** * Create a question from reading in a file in Moodle xml format @@ -323,8 +295,7 @@ public function export_to_xml($question, qformat_xml $format, $extra = null) { $fs = get_file_storage(); $textfields = $this->get_text_fields();; $formatfield = '/^('.implode('|', $textfields).')format$/'; - $fields = $this->extra_question_fields(); - array_shift($fields); // Remove table name. + $fields = constants::EXTRA_FIELDS; $output = ''; foreach ($fields as $field) { diff --git a/renderer.php b/renderer.php index 0aa4746..7b6fba8 100755 --- a/renderer.php +++ b/renderer.php @@ -308,7 +308,7 @@ protected function class_name() { } /** * Return a read only version of the response areay. Typically for after - * a quesiton has been answered and the response cannot be modified. + * a question has been answered and the response cannot be modified. * @param string $name * @param question_attempt $qa * @param question_attempt_step $step @@ -467,6 +467,181 @@ protected function filepicker_html($inputname, $draftitemid) { } } +/** + * Where the student use the HTML editor + * + * @author Marcus Green 2024 building on work by the UK OU + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_aitext_format_audio_renderer extends qtype_aitext_format_renderer_base { + /** + * Specific class name to add to the input element. + * + * @return string + */ + protected function class_name() { + return 'qtype_aitext_audiorecorder'; + } + /** + * Return a read only version of the response areay. Typically for after + * a quesiton has been answered and the response cannot be modified. + * @param string $name + * @param question_attempt $qa + * @param question_attempt_step $step + * @param int $lines number of lines in the editor + * @param object $context + * @return string + * @throws coding_exception + */ + public function response_area_read_only($name, $qa, $step, $lines, $context) { + $labelbyid = $qa->get_qt_field_name($name) . '_label'; + $responselabel = $this->displayoptions->add_question_identifier_to_label(get_string('answertext', 'qtype_aitext')); + $output = html_writer::tag('h4', $responselabel, ['id' => $labelbyid, 'class' => 'sr-only']); + $output .= html_writer::tag('div', $this->prepare_response($name, $qa, $step, $context), [ + 'role' => 'textbox', + 'aria-readonly' => 'true', + 'aria-labelledby' => $labelbyid, + 'class' => $this->class_name() . ' qtype_aitext_response readonly', + 'style' => 'min-height: ' . ($lines * 1.25) . 'em;', + ]); + // Height $lines * 1.25 because that is a typical line-height on web pages. + // That seems to give results that look OK. + + return $output; + } + + /** + * Where the student types in their response + * + * @param string $name + * @param question_attempt $qa + * @param question_attempt_step $step + * @param int $lines lines available to type in response + * @param object $context + * @return string + * @throws coding_exception + */ + public function response_area_input($name, $qa, $step, $lines, $context) { + global $CFG; + require_once($CFG->dirroot . '/repository/lib.php'); + $question = $qa->get_question(); + $inputname = $qa->get_qt_field_name($name); + $id = $inputname . '_id'; + + // get existing response and its wordcount + list($draftitemid, $response) = $this->prepare_response_for_editing($name, $step, $context); + if(!empty($response)) { + $wordcount = count_words($response); // fetch this from existing response + }else{ + $wordcount = 0; + } + + // Var response - is the existing transcript, right now we are not saving audio. + // Var inputname - is the name of the input field (hidden in this case). + // Var id - is the id of the input field (hidden in this case). + + $responselabel = $this->displayoptions->add_question_identifier_to_label(get_string('answeraudio', 'qtype_aitext')); + $output = html_writer::tag('label', $responselabel, [ + 'class' => 'sr-only', + 'for' => $id, + ]); + $output .= html_writer::start_tag('div', ['class' => + $this->class_name() . ' qtype_aitext_response']); + + // add the audio recorder + $responselanguage = $question->responselanguage; + $output .= $this->render_from_template('qtype_aitext/audiorecorder', [ + 'id' => $id, + 'inputname' => $inputname, + 'safeid' => str_replace(':', '_', $id), + 'haveresponse' => empty($response) ? false : true, + 'response' => $response, + 'waveheight' => 75, + 'asrurl' => 'https://useast.ls.poodll.com/transcribe', // TO DO - get the selected region from the question settings + 'region' => 'useast1', // TO DO - wire this up with settings from the question + 'language' => $responselanguage, + 'maxtime' => $question->maxtime, + 'wordcount' => $wordcount, + 'cancountwords' => !in_array($responselanguage, ['ja-jp', 'zh-cn', 'zh-tw', 'ko-kr']) , + ]); + + $output .= html_writer::end_tag('div'); + + return $output; + } + + /** + * Prepare the response for read-only display. + * @param string $name the variable name this input edits. + * @param question_attempt $qa the question + * being display. + * @param question_attempt_step $step the current step. + * @param object $context the context the attempt belongs to. + * @return string the response prepared for display. + */ + protected function prepare_response($name, question_attempt $qa, + question_attempt_step $step, $context) { + if (!$step->has_qt_var($name)) { + return ''; + } + + $formatoptions = new stdClass(); + $formatoptions->para = false; + return format_text($step->get_qt_var($name), $step->get_qt_var($name . 'format'), + $formatoptions); + } + + /** + * Prepare the response for editing. + * @param string $name the variable name this input edits. + * @param question_attempt_step $step the current step. + * @param object $context the context the attempt belongs to. + * @return array the response prepared for display. + */ + protected function prepare_response_for_editing($name, + question_attempt_step $step, $context) { + return [0, $step->get_qt_var($name)]; + } + + /** + * Fixed options for context and autosave is always false + * + * @param object $context the context the attempt belongs to. + * @return array options for the editor. + */ + protected function get_editor_options($context) { + // Disable the text-editor autosave because quiz has it's own auto save function. + return ['context' => $context, 'autosave' => false]; + } + + /** + * Redunant with the removal of the file submission option + * + * @todo remove calls to this then remove this + * + * @param object $context the context the attempt belongs to. + * @param int $draftitemid draft item id. + * @return array filepicker options for the editor. + */ + protected function get_filepicker_options($context, $draftitemid) { + return ['return_types' => FILE_INTERNAL | FILE_EXTERNAL]; + } + + /** + * Redundant with the removal of file submission + * + * @todo remove along with calls to it + * + * @param string $inputname input field name. + * @param int $draftitemid draft file area itemid. + * @return string HTML for the filepicker, if used. + */ + protected function filepicker_html($inputname, $draftitemid) { + return ''; + } +} + + /** * Use the HTML editor with the file picker. diff --git a/settings.php b/settings.php index 78a2da9..ed374ca 100644 --- a/settings.php +++ b/settings.php @@ -23,6 +23,8 @@ */ defined('MOODLE_INTERNAL') || die; +use qtype_aitext\constants; + if ($ADMIN->fulltree) { $settings->add(new admin_setting_configtextarea('qtype_aitext/defaultprompt', @@ -33,7 +35,9 @@ $settings->add(new admin_setting_configtextarea('qtype_aitext/defaultmarksscheme', new lang_string('defaultmarksscheme', 'qtype_aitext'), new lang_string('defaultmarksscheme_setting', 'qtype_aitext'), + new lang_string('thedefaultmarksscheme', 'qtype_aitext'))); + $settings->add(new admin_setting_configtext( 'qtype_aitext/disclaimer', new lang_string('disclaimer', 'qtype_aitext'), @@ -53,18 +57,59 @@ 'qtype_aitext/jsonprompt', new lang_string('jsonprompt', 'qtype_aitext'), new lang_string('jsonprompt_setting', 'qtype_aitext'), - 'Return only a JSON object which enumerates a set of 2 elements.The JSON object should be in - this format: {feedback":"string","marks":"number"} where marks is a single number summing all marks. - Also show the marks as part of the feedback.', + 'Return only a JSON object which enumerates a set of 4 elements.' . + ' The JSON object should be in this format: {"feedback": "string", "correctedtext": "string", "marks": "number", "relevance": "number"}' . + ' where marks is a single number summing all marks.', PARAM_RAW, 20, 6 )); + $settings->add(new admin_setting_configselect( 'qtype_aitext/responseformat', new lang_string('responseformat', 'qtype_aitext'), new lang_string('responseformat_setting', 'qtype_aitext'), - 0, ['plain' => 'plain', 'editor' => 'editor', 'monospaced' => 'monospaced'] + 'plain', constants::get_response_formats() + )); + + $settings->add(new admin_setting_configcheckbox( + 'qtype_aitext/batchmode', + new lang_string('batchmode', 'qtype_aitext'), + new lang_string('batchmode_setting', 'qtype_aitext'), + 0 + )); + $settings->add(new admin_setting_configcheckbox( + 'qtype_aitext/usecoreai', + new lang_string('usecoreai', 'qtype_aitext'), + new lang_string('usecoreai_setting', 'qtype_aitext'), + 0)); + + $settings->add(new admin_setting_configselect( + 'qtype_aitext/responselanguage', + new lang_string('responselanguage', 'qtype_aitext'), + new lang_string('responselanguage_setting', 'qtype_aitext'), + 'en-us', constants::get_languages() + )); + + $settings->add(new admin_setting_configselect( + 'qtype_aitext/feedbacklanguage', + new lang_string('feedbacklanguage', 'qtype_aitext'), + new lang_string('feedbacklanguage_setting', 'qtype_aitext'), + 'en-us', constants::get_languages(true) + )); + + $settings->add(new admin_setting_configselect( + 'qtype_aitext/maxtime', + new lang_string('maxtime', 'qtype_aitext'), + new lang_string('maxtime_setting', 'qtype_aitext'), + 60, constants::get_time_limits() + )); + + $settings->add(new admin_setting_configselect( + 'qtype_aitext/relevance', + new lang_string('relevance', 'qtype_aitext'), + new lang_string('relevance_setting', 'qtype_aitext'), + constants::RELEVANCE_NONE, constants::get_relevance_opts() )); } diff --git a/styles.css b/styles.css index ab6f65a..c0edd2c 100755 --- a/styles.css +++ b/styles.css @@ -45,4 +45,70 @@ width: 100%; max-width: 800px; background-color: #DDDDDD; -} \ No newline at end of file +} + +/*TT Recorder Styles */ +.qtype_aitext_therec_waveButtonContainer { + position: relative; + margin: auto; + border: 2px solid darkgray; + border-radius: 10px; + width: 200px; +} + +.qtype_aitext_therec_waveForm { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: white; + border-radius: 10px; +} + +.qtype_aitext_therec_waveButton { + width: 50px; + height: 50px; + border-radius: 50%; + box-shadow: inset 0px 1px 3px #ffccbd, inset 0px -1px 3px #9c282e; + color: #000; + font-size: 26px; + text-shadow: 0px -1px 0px rgba(0, 0, 0, 0.3); + -webkit-appearance: none; + -moz-appearance: none; + position: absolute; + left: 0; + right: 0; + bottom: 0; + top: 0; + margin: auto; + color: white; + border: none; + outline: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background-color: red; +} + +.qtype_aitext_audiosummary { + text-align: center; + margin: auto; + border: 2px solid darkgray; + border-radius: 10px; + width: 200px; + background-color: #EEEEEE; + padding: 10px; +} + +.qtype_aitext_questionanswered { + margin-bottom: 10px; +} + +.qtype_aitext_currentwordcount, +.qtype_aitext_timer { + text-align: center; + margin: auto; +} + diff --git a/templates/audiorecorder.mustache b/templates/audiorecorder.mustache new file mode 100644 index 0000000..1c2e0e1 --- /dev/null +++ b/templates/audiorecorder.mustache @@ -0,0 +1,30 @@ +
+
+ + + +
+ +
+
+
00:00:00
+
+
+
{{#str}} questionanswered , qtype_aitext {{/str}}
+
{{#str}} retry , qtype_aitext {{/str}}
+
+
{{#str}} currentwordcount , qtype_aitext {{/str}}: {{wordcount}}
+ + + +{{^element.frozen}} +{{#js}} +require(['jquery','qtype_aitext/transcriptmanager'], function ($,transcriber) { + var opts=[]; + opts['uniqueid']="{{safeid}}"; + opts['lang']="{{language}}"; + transcriber.init(opts); +}); +{{/js}} +{{/element.frozen}} diff --git a/tests/fixtures/testquestion.moodle.xml b/tests/fixtures/testquestion.moodle.xml index cf7934f..eb50989 100755 --- a/tests/fixtures/testquestion.moodle.xml +++ b/tests/fixtures/testquestion.moodle.xml @@ -29,6 +29,11 @@ Evaluate me One mark if correct 0 + gpt-4 + 0 + en-us + en-us + 0 diff --git a/tests/helper.php b/tests/helper.php index ff00284..58d4ba1 100755 --- a/tests/helper.php +++ b/tests/helper.php @@ -23,6 +23,7 @@ */ use Random\RandomException; +use qtype_aitext\constants; /** * Test helper class for the aitext question type. @@ -75,6 +76,11 @@ protected function initialise_aitext_question() { $q->model = 'gpt4'; $q->graderinfo = ''; $q->graderinfoformat = FORMAT_HTML; + $q->maxtime = 0; + $q->responselanguage = 'en-us'; + $q->feedbacklanguage = 'en-us'; + $q->relevance = constants::RELEVANCE_NONE; + $q->relevanceanswer = ''; $q->qtype = question_bank::get_qtype('aitext'); return $q; @@ -93,7 +99,7 @@ public function make_aitext_question_editor() { * question using the HTML editor allowing embedded files as input, and up * to three attachments. * - * @return stdClass the data that would be returned by $form->get_gata(); + * @return stdClass the data that would be returned by $form->get_data(); */ public function get_aitext_question_form_data_editor() { $fromform = new stdClass(); @@ -113,6 +119,11 @@ public function get_aitext_question_form_data_editor() { $fromform->markscheme = 'Give one mark if the answer is correct'; $fromform->sampleanswer = ''; $fromform->model = 'gpt-4'; + $fromform->maxtime = 0; + $fromform->responselanguage = 'en-us'; + $fromform->feedbacklanguage = 'en-us'; + $fromform->relevance = constants::RELEVANCE_QTEXT; + $fromform->relevanceanswer = ''; return $fromform; } @@ -151,6 +162,11 @@ public function get_aitext_question_form_data_plain() { $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $fromform->sampleanswer = ''; $fromform->model = 'gpt-4'; + $fromform->maxtime = 0; + $fromform->responselanguage = 'en-us'; + $fromform->feedbacklanguage = 'en-us'; + $fromform->relevance = constants::RELEVANCE_NONE; + $fromform->relevanceanswer = ''; return $fromform; } diff --git a/version.php b/version.php index 0e239f2..dd3dff3 100755 --- a/version.php +++ b/version.php @@ -25,10 +25,7 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_aitext'; -$plugin->version = 2024051101; +$plugin->version = 2024051103; $plugin->requires = 2020110900; $plugin->release = '0.01'; $plugin->maturity = MATURITY_BETA; -$plugin->dependencies = [ - 'tool_aiconnect' => ANY_VERSION, -];