diff --git a/psychopy/experiment/components/microphone/__init__.py b/psychopy/experiment/components/microphone/__init__.py index d71d026065..794c889c3c 100644 --- a/psychopy/experiment/components/microphone/__init__.py +++ b/psychopy/experiment/components/microphone/__init__.py @@ -13,12 +13,22 @@ from os import path from pathlib import Path from psychopy.experiment.components import BaseComponent, Param, getInitVals, _translate +from psychopy.sound.microphone import Microphone, _hasPTB +from psychopy.sound.audiodevice import sampleRateQualityLevels +from psychopy.sound.audioclip import AUDIO_SUPPORTED_CODECS from psychopy.localization import _localized as __localized -_localized = __localized.copy() +_localized = __localized.copy() _localized.update({'stereo': _translate('Stereo'), 'channel': _translate('Channel')}) +if _hasPTB: + devices = {d.deviceName: d for d in Microphone.getDevices()} +else: + devices = {} +sampleRates = {r[1]: r[0] for r in sampleRateQualityLevels.values()} +devices['default'] = None + class MicrophoneComponent(BaseComponent): """An event class for capturing short sound stimuli""" @@ -28,10 +38,15 @@ class MicrophoneComponent(BaseComponent): tooltip = _translate('Microphone: basic sound capture (fixed onset & ' 'duration), okay for spoken words') - def __init__(self, exp, parentName, name='mic_1', + def __init__(self, exp, parentName, name='mic', startType='time (s)', startVal=0.0, - stopType='duration (s)', stopVal=2.0, startEstim='', - durationEstim='', stereo=False, channel=0): + stopType='duration (s)', stopVal=2.0, + startEstim='', durationEstim='', + channels='stereo', device="default", + sampleRate='DVD Audio (48kHz)', maxSize=24000, + outputType='wav', speakTimes=True, trimSilent=False, + #legacy + stereo=None, channel=None): super(MicrophoneComponent, self).__init__( exp, parentName, name=name, startType=startType, startVal=startVal, @@ -40,88 +55,195 @@ def __init__(self, exp, parentName, name='mic_1', self.type = 'Microphone' self.url = "https://www.psychopy.org/builder/components/microphone.html" - self.exp.requirePsychopyLibs(['microphone']) + self.exp.requirePsychopyLibs(['sound']) self.order += [] + self.params['stopType'].allowedVals = ['duration (s)'] + msg = _translate( + 'The duration of the recording in seconds; blank = 0 sec') + self.params['stopType'].hint = msg + # params + msg = _translate("What microphone device would you like the use to record? This will only affect local " + "experiments - online experiments ask the participant which mic to use.") + self.params['device'] = Param( + device, valType='str', inputType="choice", categ="Basic", + allowedVals=list(devices), + hint=msg, + label=_translate("Device") + ) + msg = _translate( - "Record two channels (stereo) or one (mono, smaller file)") - self.params['stereo'] = Param( - stereo, valType='bool', inputType="bool", categ='Basic', + "Record two channels (stereo) or one (mono, smaller file). Select 'auto' to use as many channels " + "as the selected device allows.") + if stereo is not None: + # If using a legacy mic component, work out channels from old bool value of stereo + channels = ['mono', 'stereo'][stereo] + self.params['channels'] = Param( + channels, valType='str', inputType="choice", categ='Hardware', + allowedVals=['auto', 'mono', 'stereo'], hint=msg, - label=_localized['stereo']) + label=_translate('Channels')) - self.params['stopType'].allowedVals = ['duration (s)'] + msg = _translate( + "How many samples per second (Hz) to record at") + self.params['sampleRate'] = Param( + sampleRate, valType='num', inputType="choice", categ='Hardware', + allowedVals=list(sampleRates), + hint=msg, + label=_translate('Sample Rate (Hz)')) msg = _translate( - 'The duration of the recording in seconds; blank = 0 sec') - self.params['stopType'].hint = msg + "To avoid excessively large output files, what is the biggest file size you are likely to expect?") + self.params['maxSize'] = Param( + maxSize, valType='num', inputType="single", categ='Hardware', + hint=msg, + label=_translate('Max Recording Size (kb)')) - msg = _translate("Enter a channel number. Default value is 0. If unsure, run 'sound.backend.get_input_devices()'" - " to locate the system's selected device/channel.") - self.params['channel'] = Param( - channel, valType='code', inputType="single", categ="Hardware", + msg = _translate( + "What file type should output audio files be saved as?") + self.params['outputType'] = Param( + outputType, valType='code', inputType='choice', categ='Data', + allowedVals=AUDIO_SUPPORTED_CODECS, hint=msg, - label=_localized['channel']) + label=_translate("Output File Type") + ) - def writeStartCode(self, buff): - # filename should have date_time, so filename_wav should be unique - buff.writeIndented("wavDirName = filename + '_wav'\n") - buff.writeIndented("if not os.path.isdir(wavDirName):\n" - " os.makedirs(wavDirName) # to hold .wav " - "files\n") + msg = _translate( + "Tick this to save times when the participant starts and stops speaking") + self.params['speakTimes'] = Param( + speakTimes, valType='bool', inputType='bool', categ='Data', + hint=msg, + label=_translate("Speaking Start / Stop Times") + ) + + msg = _translate( + "Trim periods of silence from the output file") + self.params['trimSilent'] = Param( + trimSilent, valType='bool', inputType='bool', categ='Data', + hint=msg, + label=_translate("Trim Silent") + ) - def writeRoutineStartCode(self, buff): + def writeStartCode(self, buff): + inits = getInitVals(self.params) + # Use filename with a suffix to store recordings + code = ( + "# Make folder to store recordings from %(name)s\n" + "%(name)sRecFolder = filename + '_%(name)s_recorded'\n" + "if not os.path.isdir(%(name)sRecFolder):\n" + ) + buff.writeIndentedLines(code % inits) + buff.setIndentLevel(1, relative=True) + code = ( + "os.mkdir(%(name)sRecFolder)\n" + ) + buff.writeIndentedLines(code % inits) + buff.setIndentLevel(-1, relative=True) + + def writeInitCode(self, buff): inits = getInitVals(self.params) - code = ("%(name)s = microphone.AdvAudioCapture(name='%(name)s', " - "saveDir=wavDirName, stereo=%(stereo)s, chnl=%(channel)s)\n") - buff.writeIndented(code % inits) + # Substitute sample rate value for numeric equivalent + inits['sampleRate'] = sampleRates[inits['sampleRate'].val] + # Substitute channel value for numeric equivalent + inits['channels'] = {'mono': 1, 'stereo': 2, 'auto': None}[self.params['channels'].val] + # Substitute device name for device index + device = devices[self.params['device'].val] + if hasattr(device, "deviceIndex"): + inits['device'] = device.deviceIndex + else: + inits['device'] = None + # Create Microphone object and clips dict + code = ( + "%(name)s = sound.microphone.Microphone(\n" + ) + buff.writeIndentedLines(code % inits) + buff.setIndentLevel(1, relative=True) + code = ( + "device=%(device)s, channels=%(channels)s, \n" + "sampleRateHz=%(sampleRate)s, maxRecordingSize=%(maxSize)s\n" + ) + buff.writeIndentedLines(code % inits) + buff.setIndentLevel(-1, relative=True) + code = ( + ")\n" + "%(name)sClips = {}\n" + ) + buff.writeIndentedLines(code % inits) def writeFrameCode(self, buff): """Write the code that will be called every frame""" - duration = "%s" % self.params['stopVal'] # type is code - if not len(duration): - duration = "0" - # starting condition: - buff.writeIndented("\n") - buff.writeIndented("# *%s* updates\n" % self.params['name']) - self.writeStartTestCode(buff) # writes an if statement - buff.writeIndented("%(name)s.status = STARTED\n" % self.params) - code = "%s.record(sec=%s, block=False) # start the recording thread\n" - buff.writeIndented(code % (self.params['name'], duration)) - buff.setIndentLevel(-1, relative=True) # ends the if statement - buff.writeIndented("\n") - # these lines handle both normal end of rec thread, and user .stop(): - code = ("if %(name)s.status == STARTED and not " - "%(name)s.recorder.running:\n") - buff.writeIndented(code % self.params) - buff.writeIndented(" %s.status = FINISHED\n" % self.params['name']) + inits = getInitVals(self.params) + inits['routine'] = self.parentName + # Start the recording + code = ( + "\n" + "# %(name)s updates" + ) + buff.writeIndentedLines(code % inits) + self.writeStartTestCode(buff) + code = ( + "# start recording with %(name)s\n" + "%(name)s.start()\n" + "%(name)s.status = STARTED\n" + ) + buff.writeIndentedLines(code % inits) + buff.setIndentLevel(-1, relative=True) + # Get clip each frame + code = ( + "if %(name)s.status == STARTED:\n" + ) + buff.writeIndentedLines(code % inits) + buff.setIndentLevel(1, relative=True) + code = ( + "# update recorded clip for %(name)s\n" + "%(name)s.poll()\n" + ) + buff.writeIndentedLines(code % inits) + buff.setIndentLevel(-1, relative=True) + # Stop recording + self.writeStopTestCode(buff) + code = ( + "# stop recording with %(name)s\n" + "%(name)s.stop()\n" + "%(name)s.status = FINISHED\n" + ) + buff.writeIndentedLines(code % inits) + buff.setIndentLevel(-2, relative=True) def writeRoutineEndCode(self, buff): - # some shortcuts - name = self.params['name'] - if len(self.exp.flow._loopList): - currLoop = self.exp.flow._loopList[-1] # last (outer-most) loop - else: - currLoop = self.exp._expHandler - - # write the actual code - buff.writeIndented("# %(name)s stop & responses\n" % self.params) - buff.writeIndented("%s.stop() # sometimes helpful\n" % - self.params['name']) - buff.writeIndented("if not %(name)s.savedFile:\n" % self.params) - buff.writeIndented(" %(name)s.savedFile = None\n" % self.params) - buff.writeIndented("# store data for %s (%s)\n" % - (currLoop.params['name'], currLoop.type)) - - # always add saved file name - buff.writeIndented("%s.addData('%s.filename', %s.savedFile)\n" % - (currLoop.params['name'], name, name)) - - # get parent to write code too (e.g. store onset/offset times) - super().writeRoutineEndCode(buff) - - if currLoop.params['name'].val == self.exp._expHandler.name: - buff.writeIndented("%s.nextEntry()\n" % self.exp._expHandler.name) - # best not to do loudness / rms or other processing here + inits = getInitVals(self.params) + inits['routine'] = self.parentName + # Store recordings from this routine + code = ( + "%(name)s.bank('%(routine)s')\n" + ) + buff.writeIndentedLines(code % inits) + # Write base end routine code + BaseComponent.writeRoutineEndCode(self, buff) + + def writeExperimentEndCode(self, buff): + """Write the code that will be called at the end of + an experiment (e.g. save log files or reset hardware) + """ + inits = getInitVals(self.params) + # Save recording + code = ( + "# Save %(name)s recordings\n" + "%(name)sClips = %(name)s.flush()\n" + "for rt in %(name)sClips:\n" + ) + buff.writeIndentedLines(code % inits) + buff.setIndentLevel(1, relative=True) + code = ( + "for i, clip in enumerate(%(name)sClips[rt]):\n" + ) + buff.writeIndentedLines(code % inits) + buff.setIndentLevel(1, relative=True) + code = ( + "clipName = os.path.join(%(name)sRecFolder, f'recording_{rt}_{i}.%(outputType)s')\n" + "clip.save(clipName)\n" + ) + buff.writeIndentedLines(code % inits) + buff.setIndentLevel(-2, relative=True) diff --git a/psychopy/sound/microphone.py b/psychopy/sound/microphone.py index 5901f404f6..4c3879102f 100644 --- a/psychopy/sound/microphone.py +++ b/psychopy/sound/microphone.py @@ -261,6 +261,16 @@ def write(self, samples): d = nSamples - self._spaceRemaining return 0 if d < 0 else d + def clear(self): + # reset all live attributes + self._samples = None + self._offset = 0 + self._lastSample = 0 + self._spaceRemaining = None + self._totalSamples = None + # reallocate buffer + self._allocRecBuffer() + def getSegment(self, start=0, end=None): """Get a segment of recording data as an `AudioClip`. @@ -434,7 +444,7 @@ def __init__(self, # set the volume assert 0. <= volume <= 1. - self._stream.volume = float(volume) + #self._stream.volume = float(volume) # pre-allocate recording buffer, called once self._stream.get_audio_data(self._streamBufferSecs) @@ -448,6 +458,9 @@ def __init__(self, channels=self._channels, maxRecordingSize=maxRecordingSize) + # setup clips dict + self.clips = {} + # do the warm-up if warmUp: self.warmUp() @@ -751,6 +764,32 @@ def poll(self): return overruns + def bank(self, tag=None): + """Store current buffer as a clip within the mic object + """ + # append current recording to clip list according to tag + if tag not in self.clips: + self.clips[tag] = [] + self.clips[tag].append(self._recording.getSegment()) + # clear recording buffer + self._recording.clear() + + def clear(self): + """Wipe all clips + """ + # clear clips + self.clips = {} + # clear recording + self._recording.clear() + + def flush(self): + # get copy of clips dict + clips = self.clips.copy() + # clear + self.clear() + + return clips + def getRecording(self): """Get audio data from the last microphone recording.