Repository: psychopy/psychopy

Issue:
Issues in start/stop codes of Microphone component
I found several issues in the start/stop codes of the Microphone component.
Here attaches examples.
[mic_demo.zip](https://github.com/psychopy/psychopy/files/9488965/mic_demo.zip)
- mic_demo.psyexp: "Stop" of the Microphone is blank and the routine is stopped by a key press.
- mic_demo_5.psyexp: "Stop" of the Microphone is "5 sec". The routine can be stopped by a key press.

With PsychoPy 2022.2.4 and the latest release branch, mic_demo.psyexp stops immediately after the key press. The error message is as follows (tested on Windows 11 / PsychoPy 2022.2.4 is installed on Python 3.8.10 by pip).
```
Traceback (most recent call last):                                         
  File "..\..\Desktop\mic_demo.py", line 238, in <module>                  
    mic.stop()                                                             
  File "C:\Users\Hiroyuki\Dropbox\git-repository\psychopy\psychopy\sound\microphone.py", line 803, in stop                                            
    "Cannot stop a stream that has not been started.")                     
psychopy.sound.exceptions.AudioStreamError: Cannot stop a stream that has not been started.                                                           
```
mic_demo_5.psyexp shows the same error message when the key is pressed AFTER the Microphone is stopped, while successfully finished when the key is pressed BEFORE the Microphone is stopped.

These issues arise from multiple problems.  At first, when "Stop" is emplty, the Microphone component outputs the following code.  Condition of the second if sentence (`mic.tStartRefresh + -frameTolerance`) is clearly wrong.  Actually, this component is stopped immediately after started.
```
    if mic.status == STARTED:
        # is it time to stop? (based on global clock, using actual start)
        if tThisFlipGlobal > mic.tStartRefresh + -frameTolerance:
            # keep track of stop time/frame for later
            #(snip)
            mic.stop()
```
The second issue is the call of `mic.stop()`.  `mic.stop()` is once called by the code shown above, and called twice at the end of the Routine.  This second call causes the error.  In mic_demo_5.psyexp, the experiment successfully finished when the key is pressed BEFORE the Microphone is stopped because the routine finishes without calling the first `mic.stop()`.
```
# --- Ending Routine "trial" ---
for thisComponent in trialComponents:
    if hasattr(thisComponent, "setAutoDraw"):
        thisComponent.setAutoDraw(False)
# tell mic to keep hold of current recording in mic.clips and transcript (if applicable) in mic.scripts
# this will also update mic.lastClip and mic.lastScript
mic.stop()
```
So, we have to fix two problems.
1. handle blank "Stop" appropriately.
2. avoid double-calling of `mic.stop()`.

Before modifying codes, I looked at the dev branches on the repository and noticed that these problems seem to be fixed. However, there is another problem with the new code!  In the dev branch, `writeStartTestCode()` and `writeStopTestCode()` are added to `psychopy.experiment.components.BaseComponent` and the Microphone component uses these methods to generate code.  As a result, following code is generated by the new Microphone component.
```
    if mic.status == NOT_STARTED and t >= 0.0-frameTolerance:
        # keep track of start time/frame for later
        # (snip)
        # update status
        mic.status = STARTED
        # start recording with mic
        mic.start()
```
When we run this code, we get the following error message.
```
Traceback (most recent call last):
  File "..\..\Desktop\mic_demo_dev.py", line 163, in <module>
    mic.start()
  File "C:\Users\Hiroyuki\Dropbox\git-repository\psychopy\psychopy\sound\microphone.py", line 723, in start
    "Cannot start a stream, already started.")
psychopy.sound.exceptions.AudioStreamError: Cannot start a stream, already started.
```
The problem is the order of `mic.status = STARTED` and `mic.start()`.  The status of the component has already changed to `STARTED` before calling `mic.start()`.  In order to fix this problem, `mic.start()` must be called before `mic.status = STARTED` (though I wonder if we really need to execute `mic.status = STARTED`).  However, it seems difficult to change the order of them considering how  `BaseComponent.writeStartTestCode()` works.  What do you think is the best way to fix it?


Discussion:

Resolving patch:
diff --git a/psychopy/sound/microphone.py b/psychopy/sound/microphone.py
--- a/psychopy/sound/microphone.py
+++ b/psychopy/sound/microphone.py
@@ -12,7 +12,7 @@
 
 import sys
 import psychopy.logging as logging
-from psychopy.constants import NOT_STARTED, STARTED
+from psychopy.constants import NOT_STARTED
 from psychopy.preferences import prefs
 from .audioclip import *
 from .audiodevice import *
@@ -313,7 +313,7 @@ class Microphone:
     Creating an instance of this class will open a stream using the specified
     device. Streams should remain open for the duration of your session. When a
     stream is opened, a buffer is allocated to store samples coming off it.
-    Samples from the input stream will written to the buffer once
+    Samples from the input stream will writen to the buffer once
     :meth:`~Microphone.start()` is called.
 
     Parameters
@@ -346,9 +346,9 @@ class Microphone:
         be used.
     audioRunMode : int
         Run mode for the recording device. Default is standby-mode (`0`) which
-        allows the system to put the device to sleep. However when the device is
-        needed, waking the device results in some latency. Using a run mode of
-        `1` will keep the microphone running (or 'hot') with reduces latency
+        allows the system to put the device to sleep. However, when the device
+        is needed, waking the device results in some latency. Using a run mode
+        of `1` will keep the microphone running (or 'hot') with reduces latency
         when th recording is started. Cannot be set when after initialization at
         this time.
 
@@ -505,7 +505,7 @@ def __init__(self,
             'Allocated stream buffer to hold {} seconds of data'.format(
                 self._streamBufferSecs))
 
-        # status flag
+        # status flag for Builder
         self._statusFlag = NOT_STARTED
 
         # setup recording buffer
@@ -521,6 +521,7 @@ def __init__(self,
         self.lastClip = None
         self.scripts = {}
         self.lastScript = None
+        self._isStarted = False  # internal state
 
         logging.debug('Audio capture device #{} ready'.format(
             self._device.deviceIndex))
@@ -637,6 +638,10 @@ def status(self):
         """Status flag for the microphone. Value can be one of
         ``psychopy.constants.STARTED`` or ``psychopy.constants.NOT_STARTED``.
 
+        This is attribute is used by Builder and does not correspond to the
+        actual state of the microphone. Use `streamStatus` and `isStarted`
+        instead.
+
         For detailed stream status information, use the
         :attr:`~psychopy.sound.microphone.Microphone.streamStatus` property.
 
@@ -690,7 +695,13 @@ def isRecBufferFull(self):
     @property
     def isStarted(self):
         """``True`` if stream recording has been started (`bool`)."""
-        return self.status == STARTED
+        return self._isStarted
+
+    @property
+    def isRecording(self):
+        """``True`` if stream recording has been started (`bool`). Alias of
+        `isStarted`."""
+        return self.isStarted
 
     def start(self, when=None, waitForStart=0, stopTime=None):
         """Start an audio recording.
@@ -738,7 +749,7 @@ def start(self, when=None, waitForStart=0, stopTime=None):
             stop_time=stopTime)
 
         # recording has begun or is scheduled to do so
-        self._statusFlag = STARTED
+        self._isStarted = True
 
         logging.debug(
             'Scheduled start of audio capture for device #{} at t={}.'.format(
@@ -793,14 +804,16 @@ def stop(self, blockUntilStopped=True, stopTime=None):
 
         Returns
         -------
-        tuple
+        tuple or None
             Tuple containing `startTime`, `endPositionSecs`, `xruns` and
-            `estStopTime`.
+            `estStopTime`. Returns `None` if `stop` or `pause` was called
+            previously before `start`.
 
         """
+        # This function must be idempotent since it can be invoked at any time
+        # whether a stream is started or not.
         if not self.isStarted:
-            raise AudioStreamError(
-                "Cannot stop a stream that has not been started.")
+            return
 
         # poll remaining samples, if any
         if not self.isRecBufferFull:
@@ -809,7 +822,7 @@ def stop(self, blockUntilStopped=True, stopTime=None):
         startTime, endPositionSecs, xruns, estStopTime = self._stream.stop(
             block_until_stopped=int(blockUntilStopped),
             stopTime=stopTime)
-        self._statusFlag = NOT_STARTED
+        self._isStarted = False
 
         logging.debug(
             ('Device #{} stopped capturing audio samples at estimated time '
@@ -835,9 +848,10 @@ def pause(self, blockUntilStopped=True, stopTime=None):
 
         Returns
         -------
-        tuple
+        tuple or None
             Tuple containing `startTime`, `endPositionSecs`, `xruns` and
-            `estStopTime`.
+            `estStopTime`. Returns `None` if `stop()` or `pause()` was called
+            previously before `start()`.
 
         """
         return self.stop(blockUntilStopped=blockUntilStopped, stopTime=stopTime)
@@ -912,14 +926,20 @@ def bank(self, tag=None, transcribe=False, **kwargs):
         # make sure the tag exists in both clips and transcripts dicts
         if tag not in self.clips:
             self.clips[tag] = []
+
         if tag not in self.scripts:
             self.scripts[tag] = []
+
         # append current recording to clip list according to tag
         self.lastClip = self.getRecording()
         self.clips[tag].append(self.lastClip)
+
         # synonymise null values
-        if transcribe in ('undefined', 'NONE', 'None', 'none', 'False', 'false', 'FALSE'):
+        nullVals = (
+            'undefined', 'NONE', 'None', 'none', 'False', 'false', 'FALSE')
+        if transcribe in nullVals:
             transcribe = False
+
         # append current clip's transcription according to tag
         if transcribe:
             if transcribe in ('Built-in', True, 'BUILT_IN', 'BUILT-IN',
@@ -927,13 +947,21 @@ def bank(self, tag=None, transcribe=False, **kwargs):
                 engine = "sphinx"
             elif type(transcribe) == str:
                 engine = transcribe
+            else:
+                raise ValueError(
+                    "Invalid transcription engine {} specified.".format(
+                        transcribe))
+
             self.lastScript = self.lastClip.transcribe(
                 engine=engine, **kwargs)
         else:
             self.lastScript = "Transcription disabled."
+
         self.scripts[tag].append(self.lastScript)
+
         # clear recording buffer
         self._recording.clear()
+
         # return banked items
         if transcribe:
             return self.lastClip, self.lastScript
@@ -952,7 +980,6 @@ def flush(self):
         """Get a copy of all banked clips, then clear the clips from storage."""
         # get copy of clips dict
         clips = self.clips.copy()
-        # clear
         self.clear()
 
         return clips


Test patch: