Skip to content

Commit

Permalink
Merge pull request #3704 from TEParsons/microphone
Browse files Browse the repository at this point in the history
ENH: Implement microphone in Builder
  • Loading branch information
peircej committed Apr 12, 2021
2 parents 39c92f2 + 9929379 commit 5e5bc40
Show file tree
Hide file tree
Showing 2 changed files with 232 additions and 71 deletions.
262 changes: 192 additions & 70 deletions psychopy/experiment/components/microphone/__init__.py
Expand Up @@ -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"""
Expand All @@ -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,
Expand All @@ -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)
41 changes: 40 additions & 1 deletion psychopy/sound/microphone.py
Expand Up @@ -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`.
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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.
Expand Down

0 comments on commit 5e5bc40

Please sign in to comment.