sound.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import numpy as np
  2. import time
  3. from scipy.signal import lfilter
  4. from functools import reduce
  5. import os
  6. import threading
  7. import random
  8. class SoundController:
  9. # https://python-sounddevice.readthedocs.io/en/0.3.15/api/streams.html#sounddevice.OutputStream
  10. default_cfg = {
  11. "device": [1, 26],
  12. "n_channels": 10,
  13. "sounds": {
  14. "noise": {"amp": 0.2, "channels": [6, 8]},
  15. "background": {"freq": 660, "amp": 0.1, "duration": 0.05, "harmonics": True, "channels": [3, 8]},
  16. "target": {"freq": 1320, "amp": 0.1, "duration": 0.05, "harmonics": True, "channels": [3, 8]},
  17. "distractor1": {"freq": 860, "amp": 0.15, "duration": 0.05, "harmonics": True, "channels": [6, 8], "enabled": False},
  18. "distractor2": {"freq": 1060, "amp": 0.25, "duration": 0.05, "harmonics": True, "channels": [6, 8], "enabled": False},
  19. "distractor3": {"freq": 1320, "amp": 0.2, "duration": 0.05, "harmonics": True, "channels": [6, 8], "enabled": False}
  20. },
  21. "pulse_duration": 0.05,
  22. "sample_rate": 44100,
  23. "latency": 0.25,
  24. "volume": 0.7,
  25. "roving": 5.0,
  26. "file_path": "sounds.csv"
  27. }
  28. @classmethod
  29. def get_pure_tone(cls, freq, duration, sample_rate=44100):
  30. x = np.linspace(0, duration * freq * 2*np.pi, int(duration*sample_rate), dtype=np.float32)
  31. return np.sin(x)
  32. @classmethod
  33. def get_harm_stack(cls, base_freq, duration, threshold=1500, sample_rate=44100):
  34. harmonics = [x * base_freq for x in np.arange(20) + 2 if x * base_freq < threshold] # first 20 enouch
  35. freqs = [base_freq] + harmonics
  36. x = np.linspace(0, duration, int(sample_rate * duration))
  37. y = reduce(lambda x, y: x + y, [(1./(i+1)) * np.sin(base_freq * 2 * np.pi * x) for i, base_freq in enumerate(freqs)])
  38. return y / y.max() # norm to -1 to 1
  39. @classmethod
  40. def get_cos_window(cls, tone, win_duration, sample_rate=44100):
  41. x = np.linspace(0, np.pi/2, int(win_duration * sample_rate), dtype=np.float32)
  42. onset = np.sin(x)
  43. middle = np.ones(len(tone) - 2 * len(x))
  44. offset = np.cos(x)
  45. return np.concatenate([onset, middle, offset])
  46. @classmethod
  47. def get_tone_stack(cls, cfg):
  48. # silence
  49. silence = np.zeros(2, dtype='float32')
  50. sounds = {'silence': np.column_stack([silence for x in range(cfg['n_channels'])])}
  51. # noise
  52. filter_a = np.array([0.0075, 0.0225, 0.0225, 0.0075])
  53. filter_b = np.array([1.0000,-2.1114, 1.5768,-0.4053])
  54. noise = np.random.randn(int(0.25 * cfg['sample_rate'])) # 250ms of noise
  55. noise = lfilter(filter_a, filter_b, noise)
  56. noise = noise / np.abs(noise).max() * cfg['sounds']['noise']['amp']
  57. noise = noise.astype(np.float32)
  58. empty = np.zeros((len(noise), cfg['n_channels']), dtype='float32')
  59. for ch in cfg['sounds']['noise']['channels']:
  60. empty[:, ch-1] = noise
  61. sounds['noise'] = empty
  62. # all other sounds
  63. for key, snd in cfg['sounds'].items():
  64. if key == 'noise' or ('enabled' in snd and not snd['enabled']):
  65. continue # skip noise or unused sounds
  66. if 'harmonics' in snd and snd['harmonics']:
  67. tone = cls.get_harm_stack(snd['freq'], snd['duration'], sample_rate=cfg['sample_rate']) * cfg['volume']
  68. else:
  69. tone = cls.get_pure_tone(snd['freq'], snd['duration'], cfg['sample_rate']) * cfg['volume']
  70. tone = tone * cls.get_cos_window(tone, 0.01, cfg['sample_rate']) # onset / offset
  71. tone = tone * snd['amp'] # amplitude
  72. sound = np.zeros([len(tone), cfg['n_channels']], dtype='float32')
  73. for j in snd['channels']:
  74. sound[:, j-1] = tone
  75. sounds[key] = sound
  76. return sounds
  77. @classmethod
  78. def run(cls, selector, status, cfg):
  79. """
  80. selector mp.Value object to set the sound to be played
  81. status mp.Value object to stop the loop
  82. """
  83. import sounddevice as sd # must be inside the function
  84. import numpy as np
  85. import time
  86. commutator = {
  87. -1: 'noise',
  88. 0: 'silence',
  89. 1: 'background',
  90. 2: 'target',
  91. 3: 'distractor1',
  92. 4: 'distractor2',
  93. 5: 'distractor3',
  94. 6: 'distractor4',
  95. 7: 'distractor5'
  96. }
  97. sounds = cls.get_tone_stack(cfg)
  98. sd.default.device = cfg['device']
  99. sd.default.samplerate = cfg['sample_rate']
  100. stream = sd.OutputStream(samplerate=cfg['sample_rate'], channels=cfg['n_channels'], dtype='float32', blocksize=256)
  101. stream.start()
  102. next_beat = time.time() + cfg['latency']
  103. with open(cfg['file_path'], 'w') as f:
  104. f.write("time,id\n")
  105. while status.value > 0:
  106. if status.value == 2 or (status.value == 1 and selector.value == -1): # running state or masking noise
  107. t0 = time.time()
  108. if t0 < next_beat:
  109. #time.sleep(0.0001) # not to spin the wheels too much
  110. if stream.write_available > 2:
  111. stream.write(sounds['silence']) # silence
  112. continue
  113. roving = 10**((np.random.rand() * cfg['roving'] - cfg['roving']/2.0)/20.)
  114. roving = roving if int(selector.value) > -1 else 1 # no roving for noise
  115. stream.write(sounds[commutator[int(selector.value)]] * roving)
  116. if status.value == 2:
  117. with open(cfg['file_path'], 'a') as f:
  118. f.write(",".join([str(x) for x in (t0, selector.value)]) + "\n")
  119. next_beat += cfg['latency']
  120. if stream.write_available > 2:
  121. stream.write(sounds['silence']) # silence
  122. else: # idle state
  123. next_beat = time.time() + cfg['latency']
  124. time.sleep(0.005)
  125. stream.stop()
  126. print('Sound stopped')
  127. class ContinuousSoundStream:
  128. default_cfg = {
  129. 'wav_file': os.path.join('..', 'assets', 'stream1.wav'),
  130. 'chunk_duration': 20,
  131. 'chunk_offset': 2
  132. }
  133. def __init__(self, cfg):
  134. from scipy.io import wavfile
  135. import sounddevice as sd
  136. self.cfg = cfg
  137. self.stopped = False
  138. self.samplerate, self.data = wavfile.read(cfg['wav_file'])
  139. self.stream = sd.OutputStream(samplerate=self.samplerate, channels=2, dtype=self.data.dtype)
  140. def start(self):
  141. self._th = threading.Thread(target=self.update, args=())
  142. self._th.start()
  143. def stop(self):
  144. self.stopped = True
  145. self._th.join()
  146. print('Continuous sound stream released')
  147. def update(self):
  148. self.stream.start()
  149. print('Continuous sound stream started at %s Hz' % (self.samplerate))
  150. offset = int(self.cfg['chunk_offset'] * self.samplerate)
  151. chunk = int(self.cfg['chunk_duration'] * self.samplerate)
  152. while not self.stopped:
  153. start_idx = offset + np.random.randint(self.data.shape[0] - 2 * offset - chunk)
  154. end_idx = start_idx + chunk
  155. self.stream.write(self.data[start_idx:end_idx])
  156. self.stream.stop()
  157. class SoundControllerPR:
  158. default_cfg = {
  159. "device": [1, 26],
  160. "n_channels": 10,
  161. "sounds": {
  162. "noise": {"amp": 0.2, "duration": 2.0, "channels": [6, 8]},
  163. "target": {"freq": 660, "amp": 0.1, "duration": 2.0},
  164. },
  165. "sample_rate": 44100,
  166. "volume": 0.7,
  167. "file_path": "sounds.csv"
  168. }
  169. def __init__(self, status, cfg):
  170. import sounddevice as sd # must be inside the function
  171. import numpy as np
  172. import time
  173. sd.default.device = cfg['device']
  174. sd.default.samplerate = cfg['sample_rate']
  175. self.stream = sd.OutputStream(samplerate=cfg['sample_rate'], channels=cfg['n_channels'], dtype='float32', blocksize=256)
  176. self.stream.start()
  177. self.timers = []
  178. self.status = status
  179. self.cfg = cfg
  180. # noise (not assigned to channels)
  181. filter_a = np.array([0.0075, 0.0225, 0.0225, 0.0075])
  182. filter_b = np.array([1.0000,-2.1114, 1.5768,-0.4053])
  183. noise = np.random.randn(int(cfg['sounds']['noise']['duration'] * cfg['sample_rate']))
  184. noise = lfilter(filter_a, filter_b, noise)
  185. noise = noise / np.abs(noise).max() * cfg['sounds']['noise']['amp']
  186. noise = noise.astype(np.float32)
  187. # target (not assigned to channels)
  188. sample_rate = cfg['sample_rate']
  189. target_cfg = cfg['sounds']['target']
  190. tone = SoundController.get_pure_tone(target_cfg['freq'], target_cfg['duration'], sample_rate=cfg['sample_rate'])
  191. tone = tone * SoundController.get_cos_window(tone, target_cfg['window'], sample_rate=cfg['sample_rate'])
  192. if target_cfg['number'] > 1:
  193. silence = np.zeros( int(target_cfg['iti'] * cfg['sample_rate']) )
  194. tone_with_iti = np.concatenate([tone, silence])
  195. target = np.concatenate([tone_with_iti for i in range(target_cfg['number'] - 1)])
  196. target = np.concatenate([target, tone])
  197. else:
  198. target = tone
  199. target = target * target_cfg['amp'] # amplitude
  200. #snd = cfg['sounds']['target']
  201. #target = SoundController.get_pure_tone(snd['freq'], snd['duration'], cfg['sample_rate']) * cfg['volume']
  202. #target = target * SoundController.get_cos_window(target, 0.01, cfg['sample_rate']) # onset / offset
  203. #target = target * snd['amp'] # amplitude
  204. self.sounds = {'noise': noise, 'target': target}
  205. def target(self, hd_angle):
  206. to_play = np.zeros((len(self.sounds['target']), self.cfg['n_channels']), dtype='float32')
  207. channel = random.choice(self.cfg['sounds']['target']['channels']) # random speaker!
  208. to_play[:, channel-1] = self.sounds['target']
  209. t0 = time.time()
  210. with open(self.cfg['file_path'], 'a') as f:
  211. f.write(",".join([str(x) for x in (t0, 2, channel)]) + "\n")
  212. self.stream.write(to_play)
  213. def noise(self):
  214. to_play = np.zeros((len(self.sounds['noise']), self.cfg['n_channels']), dtype='float32')
  215. for ch in self.cfg['sounds']['noise']['channels']:
  216. to_play[:, ch-1] = self.sounds['noise']
  217. ch1 = self.cfg['sounds']['noise']['channels'][0]
  218. t0 = time.time()
  219. with open(self.cfg['file_path'], 'a') as f:
  220. f.write(",".join([str(x) for x in (t0, -1, ch1)]) + "\n")
  221. self.stream.write(to_play)
  222. def play_non_blocking(self, sound_id, hd_angle=0):
  223. if sound_id == 'target':
  224. tf = threading.Timer(0, self.target, args=[hd_angle])
  225. elif sound_id == 'noise':
  226. tf = threading.Timer(0, self.noise, args=[])
  227. tf.start()
  228. self.timers.append(tf)
  229. def stop(self):
  230. for t in self.timers:
  231. t.cancel()
  232. self.stream.stop()