OSCR

Attention to speech modulates distortion product otoacoustic emissions evoked by speech-derived stimuli in humans.

Code ↔ Paper

4 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 4 matches · 1 of them tie a paragraph to a whole file, not to given lines: a weak match, whose lines are not tinted
  1. [1] § Materials and methods › Stimuli for eliciting speech-like DPOAEs ↔ stimulus_generation.py, lines 124–148 · score 0.75 · bandpass filter, standard deviations, fundamental waveform, fundamental frequency, signal, Stimuli
  2. [2] § Materials and methods › Stimuli for eliciting speech-like DPOAEs ↔ stimulus_generation.py, lines 150–183 · score 0.70 · Hilbert transform, harmonic overtones, fundamental waveform, Stimuli
  3. [3] § Materials and methods › Analysis of speech-like DPOAEs ↔ DPOAE_analysis.py, lines 664–728 · score 0.66 · imaginary part, complex cross correlation, microphone recording, envelope, waveforms, DPOAEs
  4. [4] § Materials and methods › Analysis of speech-like DPOAEs ↔ cross_correlation.py, the whole file · a weak match · score 0.56 · imaginary part, complex cross correlation, envelope, signal

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 661 lines · 30 KB · MIT · 2 matches

  1. import numpy as np
  2. import librosa
  3. from pathlib import Path
  4. import os
  5. import re
  6. import soundfile as sf
  7. import pandas as pd
  8. import scipy.signal as sig
  9. import matplotlib.pyplot as plt
  10. from collections.abc import Iterable
  11. from plot_stimulus_correlations import stimuli_correlation
  12. from butterworth_filter import butter_filter
  13. class stimulus_generator:
  14. """Generates stimuli for speech-DPOAE measurements. Stimuli follow the specified harmonic numbers n1 and n2.
  15. Distortion product is calculated as 2*n1 - n2 and can be changed via distortion_product method.
  16. Parameters
  17. ----------
  18. n1 : list or int
  19. The lower harmonic number(s) for the stimuli.
  20. n2 : list or int
  21. The upper harmonic number(s) for the stimuli.
  22. voice : str
  23. The voice type of the source audio, setting the fundamental frequency range.
  24. Can either be 'male' or 'female'.
  25. source_dir : str or Path
  26. The directory containing the source audio files.
  27. target_dir : str or Path
  28. The directory where the generated stimuli will be saved.
  29. fs : int, optional
  30. The sampling frequency of the audio files, by default 44100.
  31. """
  32. def __init__(self, n1, n2, voice, source_dir, target_dir, fs=44100):
  33. self.fs = fs
  34. self.n1 = np.array(n1)
  35. self.n2 = np.array(n2)
  36. if len(self.n1) != len(self.n2):
  37. raise ValueError("n1 and n2 must have the same length.")
  38. self.dp = self.distortion_product(self.n1, self.n2)
  39. self.source_dir = Path(source_dir)
  40. self.target_dir = Path(target_dir)
  41. if not os.path.exists(self.target_dir):
  42. os.makedirs(self.target_dir)
  43. self.voice = voice
  44. if self.voice == 'female':
  45. self.f0_min, self.f0_max = 80, 300
  46. elif self.voice == 'male':
  47. self.f0_min, self.f0_max = 50, 200
  48. else:
  49. raise ValueError('Unknown voice.')
  50. #####################################################################
  51. def distortion_product(self, n1, n2):
  52. """Calculate the distortion product based on the provided harmonic numbers."""
  53. if isinstance(n1, Iterable) and isinstance(n2, Iterable):
  54. return 2 * np.array(n1) - np.array(n2)
  55. else:
  56. return 2 * n1 - n2
  57. def generate_sine_wave(self, frequency, duration, nrm=0.01):
  58. """Generate a sine wave of a given frequency and duration."""
  59. t = np.linspace(0, duration, int(self.fs * duration), endpoint=False)
  60. wave = nrm * np.sin(2 * np.pi * frequency * t)
  61. return wave
  62. def save_wave(self, wave, filename):
  63. """Save the generated waveform to a file."""
  64. if not os.path.exists(os.path.dirname(filename)):
  65. os.makedirs(os.path.dirname(filename))
  66. sf.write(filename, wave, self.fs)
  67. def zscore_norm(self, data):
  68. """Normalize the data using z-score normalization."""
  69. mean = np.mean(data)
  70. std = np.std(data)
  71. return (data - mean) / std
  72. ###################################################################
  73. def load_source_audio(self):
  74. """Load all audio files from the source directory."""
  75. self.source_files = sorted(list(self.source_dir.glob('*.wav')))
  76. if not self.source_files:
  77. raise FileNotFoundError("No .wav files found in the source directory.")
  78. return
  79. def determine_f0(self, audio_array, harm):
  80. """Estimate the fundamental frequency (f0) of the audio signal using the pyin algorithm.
  81. Parameters
  82. ----------
  83. audio_array : np.ndarray
  84. The audio signal from which to estimate the f0.
  85. harm : int
  86. The harmonic number for which to estimate the f0.
  87. Returns
  88. -------
  89. f0_mean : float
  90. The mean of the estimated f0 values.
  91. f0_std : float
  92. The standard deviation of the estimated f0 values.
  93. """
  94. # Parameters for pyin algorithm
  95. fr_length = 4000
  96. win_length = 3300
  97. hop_length = 1250
  98. # Estimate the fundamental frequency (f0)
  99. f0, voiced_flag, _ = librosa.pyin(audio_array, sr=self.fs, fmin=(harm*self.f0_min), fmax=(harm*self.f0_max), frame_length=fr_length, win_length=win_length, hop_length=hop_length)
  100. # Calculate mean and standard deviation of f0
  101. f0_mean = np.mean(f0[voiced_flag])
  102. f0_std = np.std(f0[voiced_flag])
  103. return f0_mean, f0_std
  104. #######################################################################
  105. def gen_fundamental_waveform(self, x, f0_mean, f0_std):
  106. """Generate the fundamental waveform of the audio signal x by bandpass filtering around the fundamental frequency of the signal.
  107. Filter bandwidth is defined as f0_mean +/- f0_std/2.
  108. Parameters
  109. ----------
  110. x : np.ndarray
  111. The audio signal from which to generate the fundamental waveform.
  112. f0_mean : float
  113. The mean of the estimated fundamental frequency.
  114. f0_std : float
  115. The standard deviation of the estimated fundamental frequency.
  116. Returns
  117. -------
  118. fund_wf : np.ndarray
  119. The generated fundamental waveform.
  120. """
  121. lowcut = np.round(f0_mean - f0_std/2, 1)
  122. highcut = np.round(f0_mean + f0_std/2, 1)
  123. fund_wf = butter_filter(x, [lowcut, highcut], 'bandpass', fs=self.fs)
  124. fund_wf = self.zscore_norm(fund_wf)
  125. return fund_wf
  126. def gen_stimuli_hilbert(self, n, fund_wf, nrm=0.01):
  127. """Generate the stimulus waveform for the nth harmonic overtone using the Hilbert transform of the fundamental waveform.
  128. Parameters
  129. ----------
  130. n : int
  131. The harmonic number for which to generate the stimulus waveform.
  132. fund_wf : np.ndarray
  133. The fundamental waveform of the audio signal.
  134. nrm : float, optional
  135. The normalization factor for the generated waveform, by default 0.01.
  136. Returns
  137. -------
  138. stim : np.ndarray
  139. The generated stimulus waveform for the nth harmonic overtone.
  140. """
  141. fund_wf_orig = fund_wf.copy()
  142. # remove nans from fund_wf and replace with zeros
  143. fund_wf = np.nan_to_num(fund_wf_orig)
  144. # as the hilbert transform expects a periodic signal, concatenate the waveform with itself
  145. fund_wf = np.concatenate((fund_wf[::-1], fund_wf))
  146. # apply the hilbert transform to get the amplitude and phase
  147. wf_hilbert = sig.hilbert(fund_wf)
  148. amp = np.abs(wf_hilbert[len(fund_wf_orig):])
  149. phase = np.unwrap(np.angle(wf_hilbert[len(fund_wf_orig):])) # halbe Länge, weil wir ja concatenated hatten
  150. stim = nrm * np.nan_to_num(amp * np.cos(phase * n))
  151. stim = stim - np.mean(stim) # remove DC offset
  152. return stim
  153. ####################################################################
  154. def create_pure_tone_stimuli(self, f1, f2, duration=120):
  155. """Creates pure tone stimuli based on the specified frequencies.
  156. Parameters
  157. ----------
  158. f1 : float
  159. The frequency of the first pure tone in Hz.
  160. f2 : float
  161. The frequency of the second pure tone in Hz.
  162. duration : float, optional
  163. The duration of the stimuli in seconds, by default 120 seconds.
  164. """
  165. pt_dp = self.distortion_product(f1, f2)
  166. stim1 = self.generate_sine_wave(f1, duration)
  167. stim2 = self.generate_sine_wave(f2, duration)
  168. stimdp = self.generate_sine_wave(pt_dp, duration)
  169. stereo_stim = np.vstack((stim1, stim2)).T
  170. self.save_wave(stereo_stim, self.target_dir.parent / f'stereo_pt.wav')
  171. self.save_wave(stimdp, self.target_dir.parent / f'dp_pt.wav')
  172. return
  173. def create_stimuli(self):
  174. """Create stimuli based on the source audio files and the specified harmonic numbers."""
  175. # Load source audio files
  176. self.load_source_audio()
  177. for audio_file in self.source_files:
  178. audio_name = audio_file.stem
  179. audio_array, _ = librosa.load(audio_file, sr=self.fs)
  180. audio_array = audio_array / np.max(np.abs(audio_array)) # normalize audio array
  181. #audio_array = audio_array - np.mean(audio_array) # remove DC offset
  182. stim_columns = ['filename', 'f0_mean', 'f0_std', 'harm', 'harm_freq [Hz]', 'harm_std [Hz]']
  183. csv_stimuli = []
  184. f0_mean, f0_std = self.determine_f0(audio_array, 1)
  185. # Generate the fundamental waveform
  186. fund_wf = self.gen_fundamental_waveform(audio_array, f0_mean, f0_std)
  187. # Generate and save stimulus waveforms
  188. for harms in [self.n1, self.n2, self.dp]:
  189. if isinstance(harms, Iterable):
  190. for n in harms:
  191. wf = self.gen_stimuli_hilbert(n, fund_wf)
  192. freq_stim, std_stim = self.determine_f0(wf, n)
  193. filename = f"{self.target_dir}/mono_stimuli/{audio_name}_n{n}.wav"
  194. self.save_wave(wf, filename)
  195. csv_stimuli.append([audio_name, f0_mean, f0_std, n, freq_stim, std_stim])
  196. else:
  197. n = harms
  198. wf = self.gen_stimuli_hilbert(n, fund_wf)
  199. freq_stim, std_stim = self.determine_f0(wf, n)
  200. filename = f"{self.target_dir}/mono_stimuli/{audio_name}_n{n}.wav" # das passt noch nicht
  201. self.save_wave(wf, filename)
  202. csv_stimuli.append([audio_name, f0_mean, f0_std, n, freq_stim, std_stim])
  203. self.determine_stimulus_correlations(audio_name)
  204. csv_stimuli.append(['-' * 10, '-' * 10, '-' * 10, '-' * 10, '-' * 10, '-' * 10]) # separator for different audio files
  205. csv_stimuli_df = pd.DataFrame(csv_stimuli, columns=stim_columns)
  206. csv_path = self.target_dir / f'stimuli_info.csv'
  207. write_header = not os.path.exists(csv_path)
  208. csv_stimuli_df.to_csv(csv_path, mode='a', header=write_header, index=False)
  209. def determine_stimulus_correlations(self, audio_name):
  210. """Determine the correlations between the generated stimuli and the distortion product waveform."""
  211. sig_columns = ['filename', 'n1', 'n2', 'dp', 'sig_peak']
  212. csv_correlations = []
  213. stim_path = self.target_dir / 'mono_stimuli/'
  214. plot_path = self.target_dir / 'mono_stimuli/Plots/'
  215. # determine stimulus correlations
  216. if isinstance(self.n1, Iterable):
  217. for n1, n2, dp in zip(self.n1, self.n2, self.dp):
  218. stim_n1 = librosa.load(stim_path / f'{audio_name}_n{n1}.wav', sr=self.fs)[0]
  219. stim_n2 = librosa.load(stim_path / f'{audio_name}_n{n2}.wav', sr=self.fs)[0]
  220. stim_dp = librosa.load(stim_path / f'{audio_name}_n{dp}.wav', sr=self.fs)[0]
  221. sig_peak = stimuli_correlation(dp, stim_dp, stim_n1, stim_n2, filename=plot_path / f"corr_{audio_name}_n{n1}n{n2}_dp{dp}.png", fs=self.fs)
  222. csv_correlations.append([audio_name, n1, n2, dp, sig_peak])
  223. else:
  224. # if n1, n2, dp are not lists, just use them directly
  225. stim_n1 = librosa.load(stim_path / f'{audio_name}_n{self.n1}.wav', sr=self.fs)[0]
  226. stim_n2 = librosa.load(stim_path / f'{audio_name}_n{self.n2}.wav', sr=self.fs)[0]
  227. stim_dp = librosa.load(stim_path / f'{audio_name}_n{self.dp}.wav', sr=self.fs)[0]
  228. sig_peak = stimuli_correlation(dp, stim_dp, stim_n1, stim_n2, filename=plot_path / f"corr_{audio_name}_n{self.n1}n{self.n2}_dp{self.dp}.png", fs=self.fs)
  229. csv_correlations.append([audio_name, self.n1, self.n2, self.dp, sig_peak])
  230. sig_df = pd.DataFrame(csv_correlations, columns=sig_columns)
  231. csv_path = self.target_dir / f'stimuli_correlations.csv'
  232. write_header = not os.path.exists(csv_path)
  233. sig_df.to_csv(csv_path, mode='a', header=write_header, index=False)
  234. return sig_peak
  235. def create_stereo_stimuli(self):
  236. """Create stereo stimuli by stacking the mono stimuli."""
  237. mono_stim_path = self.target_dir / 'mono_stimuli/'
  238. out_path = self.target_dir / 'stereo_stimuli/'
  239. self.load_source_audio()
  240. for audio_file in self.source_files:
  241. audio_name = audio_file.stem
  242. if isinstance(self.n1, list):
  243. for n1, n2 in zip(self.n1, self.n2):
  244. stim_n1 = librosa.load(mono_stim_path / f'{audio_name}_n{n1}.wav', sr=self.fs)[0]
  245. stim_n2 = librosa.load(mono_stim_path / f'{audio_name}_n{n2}.wav', sr=self.fs)[0]
  246. # create stereo stimulus
  247. stereo_stim = np.vstack((stim_n1, stim_n2)).T
  248. filename = out_path / f'{audio_name}_n{n1}n{n2}.wav'
  249. self.save_wave(stereo_stim, filename)
  250. else:
  251. stim_n1 = librosa.load(mono_stim_path / f'{audio_name}_n{self.n1}.wav', sr=self.fs)[0]
  252. stim_n2 = librosa.load(mono_stim_path / f'{audio_name}_n{self.n2}.wav', sr=self.fs)[0]
  253. # create stereo stimulus
  254. stereo_stim = np.vstack((stim_n1, stim_n2)).T
  255. filename = out_path / f'{audio_name}_n{self.n1}n{self.n2}.wav'
  256. self.save_wave(stereo_stim, filename)
  257. return
  258. class multiband_generator:
  259. def __init__(self, stim_directory, bookA, bookB=None, fs=44100):
  260. """
  261. Initialize the multiband generator with the directory containing stimuli and the books to be combined.
  262. Parameters
  263. ----------
  264. stim_directory : str or Path
  265. The directory where the stimulus files are located.
  266. bookA : str
  267. The name of the folder containing the mono stimuli for book A.
  268. Typically, it should follow the format 'Name_Voice'.
  269. bookB : str
  270. The name of the folder containing the mono stimuli for book B.
  271. Typically, it should follow the format 'Name_Voice'.
  272. If not provided, only book A will be used.
  273. fs : int, optional
  274. The sampling frequency of the audio files, by default 44100.
  275. """
  276. self.stim_directory = Path(stim_directory)
  277. self.bookA = bookA
  278. self.bookB = bookB
  279. self.fs = fs
  280. def distorion_product(self, n1, n2):
  281. """
  282. Calculate the distortion product based on the provided harmonic numbers.
  283. Parameters
  284. ----------
  285. n1 : int or list of int
  286. The first harmonic number(s).
  287. n2 : int or list of int
  288. The second harmonic number(s).
  289. Returns
  290. -------
  291. np.ndarray
  292. The distortion product waveform.
  293. """
  294. if isinstance(n1, Iterable) and isinstance(n2, Iterable):
  295. return 2 * np.array(n1) - np.array(n2)
  296. else:
  297. return 2 * n1 - n2
  298. def save_wave(self, wave, filename):
  299. """Save the generated waveform to a file."""
  300. if not os.path.exists(os.path.dirname(filename)):
  301. os.makedirs(os.path.dirname(filename))
  302. sf.write(filename, wave, self.fs)
  303. def extract_chapter_number(self, filename):
  304. """Extract the chapter number from the filename."""
  305. base = re.sub(r'_n\d+$', '', filename) # remove the n1 or n2 suffix
  306. match = re.findall(r'(?<!\d)(\d{2})(?!\d)', base) # find two-digit numbers
  307. return match[0] if match else None
  308. def load_stim_files(self, book):
  309. """Load all audio files for the respective book from the stimulus directory."""
  310. mono_stim_path = self.stim_directory / book / 'mono_stimuli'
  311. stim_files = sorted(list(mono_stim_path.glob('*.wav')))
  312. if not stim_files:
  313. raise FileNotFoundError("No .wav files found in the stimulus directory.")
  314. return stim_files
  315. def create_multiband_singlespeaker(self, all_n1, all_n2, book, nrm=0.01):
  316. """Create multiband stimuli by combining the mono stimuli from book A and book B.
  317. The resulting stimuli will be saved in a new directory named 'multiband_stimuli'.
  318. Parameters
  319. ----------
  320. all_n1 : list of int
  321. The harmonic numbers for the first band that will be combined.
  322. all_n2 : list of int
  323. The harmonic numbers for the second band that will be combined.
  324. book : str
  325. The name of the book for which the multiband stimuli will be created.
  326. nrm : float, optional
  327. The normalization factor for the generated waveform, by default 0.01.
  328. """
  329. # Create target directory for multiband stimuli
  330. target_dir = self.stim_directory / book / 'multiband_stimuli'
  331. if not os.path.exists(target_dir):
  332. os.makedirs(target_dir)
  333. # Load stimuli for book A only
  334. mono_stimuli = self.load_stim_files(book)
  335. chapters = sorted(set(self.extract_chapter_number(file.stem) for file in mono_stimuli if 'single' in file.stem))
  336. for chapter in chapters:
  337. chapter_files = [file for file in mono_stimuli if self.extract_chapter_number(file.stem) == chapter and 'single' in file.stem]
  338. base_name = '_'.join(chapter_files[0].stem.split('_')[:3])
  339. harms_name = ''.join([f'n{n1}' for n1 in all_n1]) + '_' + ''.join([f'n{n2}' for n2 in all_n2])
  340. multiband_n1 = np.array([])
  341. multiband_n2 = np.array([])
  342. n1_files = [file for file in chapter_files if any(f'n{n1}' in file.stem for n1 in all_n1)]
  343. n2_files = [file for file in chapter_files if any(f'n{n2}' in file.stem for n2 in all_n2)]
  344. if len(n1_files) != len(n2_files):
  345. raise ValueError(f"Number of n1 and n2 files do not match for chapter {chapter} in book {book}.")
  346. for n1_file, n2_file in zip(n1_files, n2_files):
  347. # Load the audio files
  348. stim_n1, _ = librosa.load(n1_file, sr=self.fs)
  349. stim_n2, _ = librosa.load(n2_file, sr=self.fs)
  350. if multiband_n1.shape[0] == 0 and multiband_n2.shape[0] == 0:
  351. multiband_n1 = stim_n1
  352. multiband_n2 = stim_n2
  353. else:
  354. multiband_n1 += stim_n1
  355. multiband_n2 += stim_n2
  356. # Normalize the multiband stimuli
  357. multiband_n1 = nrm * multiband_n1 / np.max(np.abs(multiband_n1))
  358. multiband_n2 = nrm * multiband_n2 / np.max(np.abs(multiband_n2))
  359. # remove DC offset
  360. multiband_n1 = multiband_n1 - np.mean(multiband_n1)
  361. multiband_n2 = multiband_n2 - np.mean(multiband_n2)
  362. # save them as stereo stimuli
  363. multiband_stim = np.vstack((multiband_n1, multiband_n2)).T
  364. filename = target_dir / f'{base_name}_{harms_name}.wav'
  365. self.save_wave(multiband_stim, filename)
  366. # calculate correlation between dp waveforms and multiband stimuli
  367. dp = self.distorion_product(all_n1, all_n2)
  368. dp_files = [file for file in chapter_files if any(f'n{d}' in file.stem for d in dp)]
  369. sig_peak = []
  370. for ndp, dp_file in zip(dp, dp_files):
  371. wf_dp, _ = librosa.load(dp_file, sr=self.fs)
  372. sig_peak.append(stimuli_correlation(ndp, wf_dp, multiband_n1, multiband_n2, filename=target_dir / f"corr_{base_name}_{harms_name}_dp{ndp}.png", fs=self.fs))
  373. # Save the correlation results to a CSV file
  374. multiband_columns = ['filename', 'n1', 'n2', 'dp', 'sig_peak']
  375. multiband_data = np.array([[base_name for i in range(len(sig_peak))], ['n'.join(map(str, all_n1)) for i in range(len(sig_peak))], ['n'.join(map(str, all_n2)) for i in range(len(sig_peak))], list(dp), sig_peak]).T
  376. multiband_csv = pd.DataFrame(multiband_data, columns=multiband_columns)
  377. csv_path = self.stim_directory / book / f'multiband_correlations_{book}_{harms_name}.csv'
  378. write_header = not os.path.exists(csv_path)
  379. multiband_csv.to_csv(csv_path, mode='a', header=write_header, index=False)
  380. return
  381. def create_multiband_competingspeaker(self, bookA, bookB, A_n1, A_n2, B_n1, B_n2, nrm=0.01):
  382. """Create multiband stimuli by combining the mono stimuli from book A and book B.
  383. The resulting stimuli will be saved in a new directory named 'competing_stimuli_{bookA}_{bookB}'.
  384. Parameters
  385. ----------
  386. bookA : str
  387. The name of the folder containing the mono stimuli for book A.
  388. bookB : str
  389. The name of the folder containing the mono stimuli for book B.
  390. A_n1 : list of int
  391. The harmonic numbers for the first band in book A that will be combined.
  392. A_n2 : list of int
  393. The harmonic numbers for the second band in book A that will be combined.
  394. B_n1 : list of int
  395. The harmonic numbers for the first band in book B that will be combined.
  396. B_n2 : list of int
  397. The harmonic numbers for the second band in book B that will be combined.
  398. nrm : float, optional
  399. The normalization factor for the generated waveform, by default 0.01.
  400. """
  401. target_dir = self.stim_directory / f'competing_stimuli_{self.bookA}_{self.bookB}'
  402. if not os.path.exists(target_dir):
  403. os.makedirs(target_dir)
  404. stim_bookA = self.load_stim_files(bookA)
  405. stim_bookB = self.load_stim_files(bookB)
  406. # remove potential single speaker files
  407. stim_bookA = [file for file in stim_bookA if 'single' not in file.stem]
  408. stim_bookB = [file for file in stim_bookB if 'single' not in file.stem]
  409. chapters_A = sorted(set(self.extract_chapter_number(file.stem) for file in stim_bookA))
  410. chapters_B = sorted(set(self.extract_chapter_number(file.stem) for file in stim_bookB))
  411. if chapters_A != chapters_B:
  412. raise ValueError("Chapters in book A and book B do not match.")
  413. for chapter in chapters_A:
  414. chapter_files_A = [file for file in stim_bookA if self.extract_chapter_number(file.stem) == chapter]
  415. chapter_files_B = [file for file in stim_bookB if self.extract_chapter_number(file.stem) == chapter]
  416. base_name = 'Ch_' + chapter
  417. harms_A = ''.join([f'n{n1}' for n1 in A_n1]) + '_' + ''.join([f'n{n2}' for n2 in A_n2])
  418. harms_B = ''.join([f'n{n1}' for n1 in B_n1]) + '_' + ''.join([f'n{n2}' for n2 in B_n2])
  419. multiband_n1 = np.array([])
  420. multiband_n2 = np.array([])
  421. n1_files_A = [file for file in chapter_files_A if any(f'n{n1}' in file.stem for n1 in A_n1)]
  422. n2_files_A = [file for file in chapter_files_A if any(f'n{n2}' in file.stem for n2 in A_n2)]
  423. n1_files_B = [file for file in chapter_files_B if any(f'n{n1}' in file.stem for n1 in B_n1)]
  424. n2_files_B = [file for file in chapter_files_B if any(f'n{n2}' in file.stem for n2 in B_n2)]
  425. if len(n1_files_A) != len(n2_files_A) or len(n1_files_B) != len(n2_files_B):
  426. raise ValueError(f"Number of n1 and n2 files do not match for chapter {chapter}.")
  427. for n1_file_A, n1_file_B, n2_file_A, n2_file_B in zip(n1_files_A, n1_files_B, n2_files_A, n2_files_B):
  428. # Load the audio files
  429. stim_n1_A, _ = librosa.load(n1_file_A, sr=self.fs)
  430. stim_n2_A, _ = librosa.load(n2_file_A, sr=self.fs)
  431. stim_n1_B, _ = librosa.load(n1_file_B, sr=self.fs)
  432. stim_n2_B, _ = librosa.load(n2_file_B, sr=self.fs)
  433. if len(stim_n1_A) > len(stim_n1_B):
  434. stim_n1_B = np.pad(stim_n1_B, (0, len(stim_n1_A) - len(stim_n1_B)), 'constant')
  435. elif len(stim_n1_B) > len(stim_n1_A):
  436. stim_n1_A = np.pad(stim_n1_A, (0, len(stim_n1_B) - len(stim_n1_A)), 'constant')
  437. if len(stim_n2_A) > len(stim_n2_B):
  438. stim_n2_B = np.pad(stim_n2_B, (0, len(stim_n2_A) - len(stim_n2_B)), 'constant')
  439. elif len(stim_n2_B) > len(stim_n2_A):
  440. stim_n2_A = np.pad(stim_n2_A, (0, len(stim_n2_B) - len(stim_n2_A)), 'constant')
  441. if multiband_n1.shape[0] == 0 and multiband_n2.shape[0] == 0:
  442. multiband_n1 = stim_n1_A + stim_n1_B
  443. multiband_n2 = stim_n2_A + stim_n2_B
  444. else:
  445. multiband_n1 += stim_n1_A + stim_n1_B
  446. multiband_n2 += stim_n2_A + stim_n2_B
  447. # Normalize the multiband stimuli
  448. multiband_n1 = nrm * multiband_n1 / np.max(np.abs(multiband_n1))
  449. multiband_n2 = nrm * multiband_n2 / np.max(np.abs(multiband_n2))
  450. # save them as stereo stimuli
  451. multiband_stim = np.vstack((multiband_n1, multiband_n2)).T
  452. filename = target_dir / f'{base_name}_{bookA}_{harms_A}_{bookB}_{harms_B}.wav'
  453. self.save_wave(multiband_stim, filename)
  454. # calculate correlation between dp waveforms and multiband stimuli
  455. dp_A = self.distorion_product(A_n1, A_n2)
  456. dp_B = self.distorion_product(B_n1, B_n2)
  457. dp_files_A = [file for file in chapter_files_A if any(f'n{d}' in file.stem for d in dp_A)]
  458. dp_files_B = [file for file in chapter_files_B if any(f'n{d}' in file.stem for d in dp_B)]
  459. sig_peak = []
  460. for i, (dp_file_A, dp_file_B) in enumerate(zip(dp_files_A, dp_files_B)):
  461. wf_dp_A, _ = librosa.load(dp_file_A, sr=self.fs)
  462. wf_dp_B, _ = librosa.load(dp_file_B, sr=self.fs)
  463. res_A = stimuli_correlation(dp_A[i], wf_dp_A, multiband_n1, multiband_n2, filename=target_dir / f"corr_{base_name}_{bookA}_dp{dp_A[i]}.png", fs=self.fs)
  464. res_B = stimuli_correlation(dp_B[i], wf_dp_B, multiband_n1, multiband_n2, filename=target_dir / f"corr_{base_name}_{bookB}_dp{dp_B[i]}.png", fs=self.fs)
  465. sig_peak.append((res_A, res_B))
  466. # Save the correlation results to a CSV file
  467. multiband_columns = ['filename', 'book A', 'book B', 'n1 (book A)', 'n2 (book A)', 'dp (book A)', 'n1 (book B)', 'n2 (book B)', 'dp (book B)', 'sig_peak_A', 'sig_peak_B']
  468. multiband_data = np.array([[base_name for i in range(len(sig_peak))], [bookA for i in range(len(sig_peak))], [bookB for i in range(len(sig_peak))], ['n'.join(map(str, A_n1)) for i in range(len(sig_peak))], ['n'.join(map(str, A_n2)) for i in range(len(sig_peak))], list(dp_A), ['n'.join(map(str, B_n1)) for i in range(len(sig_peak))], ['n'.join(map(str, B_n2)) for i in range(len(sig_peak))], list(dp_B),[res[0] for res in sig_peak], [res[1] for res in sig_peak]]).T
  469. multiband_csv = pd.DataFrame(multiband_data, columns=multiband_columns)
  470. csv_path = self.stim_directory / f'multiband_correlations_{bookA}_{harms_A}_{bookB}_{harms_B}.csv'
  471. write_header = not os.path.exists(csv_path)
  472. multiband_csv.to_csv(csv_path, mode='a', header=write_header, index=False)
  473. return
  474. def mix_books(self, audio_path, bookA, bookB, nrm=0.02):
  475. """Mix the audio files from book A and book B into stereo stimuli.
  476. The resulting stimuli will be saved in a new directory named 'competing_audios_{bookA}_{bookB}'.
  477. Parameters
  478. ----------
  479. audio_path : str or Path
  480. The directory where the source audio files are located.
  481. bookA : str
  482. The name of the folder containing the audio files for book A.
  483. bookB : str
  484. The name of the folder containing the audio files for book B.
  485. nrm : float, optional
  486. The normalization factor for the generated waveform, by default 0.02.
  487. """
  488. audio_path = Path(audio_path)
  489. single_dir = audio_path.parent / 'single_audios/'
  490. if not os.path.exists(single_dir):
  491. os.makedirs(single_dir)
  492. target_dir = audio_path.parent / f'competing_audios_{self.bookA}_{self.bookB}'
  493. if not os.path.exists(target_dir):
  494. os.makedirs(target_dir)
  495. path_bookA = audio_path / bookA
  496. path_bookB = audio_path / bookB
  497. source_files_A = sorted(list(path_bookA.glob('*.wav')))
  498. source_files_B = sorted(list(path_bookB.glob('*.wav')))
  499. # extract potential single speaker files
  500. source_files_single = [file for file in source_files_A + source_files_B if 'single' in file.stem]
  501. # save single speaker files separately
  502. for file in source_files_single:
  503. single_audio = librosa.load(file, sr=self.fs)[0]
  504. single_audio = nrm * single_audio / np.max(np.abs(single_audio))
  505. self.save_wave(single_audio, single_dir / file.name)
  506. source_files_A = [file for file in source_files_A if file not in source_files_single]
  507. source_files_B = [file for file in source_files_B if file not in source_files_single]
  508. if len(source_files_A) != len(source_files_B):
  509. raise ValueError(f"Number of audio files in {bookA} and {bookB} do not match.")
  510. # mix remaining chapters together
  511. chapters_A = sorted(set(self.extract_chapter_number(file.stem) for file in source_files_A))
  512. chapters_B = sorted(set(self.extract_chapter_number(file.stem) for file in source_files_B))
  513. if chapters_A != chapters_B:
  514. raise ValueError("Chapters in book A and book B do not match.")
  515. for chapter in chapters_A:
  516. chapter_files_A = [file for file in source_files_A if self.extract_chapter_number(file.stem) == chapter]
  517. chapter_files_B = [file for file in source_files_B if self.extract_chapter_number(file.stem) == chapter]
  518. base_name = 'Ch_' + chapter
  519. audio_A = librosa.load(chapter_files_A[0], sr=self.fs)[0]
  520. audio_B = librosa.load(chapter_files_B[0], sr=self.fs)[0]
  521. audio_A = nrm * audio_A / np.max(np.abs(audio_A))
  522. audio_B = nrm * audio_B / np.max(np.abs(audio_B))
  523. if len(audio_A) > len(audio_B):
  524. audio_B = np.pad(audio_B, (0, len(audio_A) - len(audio_B)), 'constant')
  525. elif len(audio_B) > len(audio_A):
  526. audio_A = np.pad(audio_A, (0, len(audio_B) - len(audio_A)), 'constant')
  527. mixed_audio = np.vstack((audio_A, audio_B)).T
  528. filename = target_dir / f'{base_name}_{bookA}_{bookB}.wav'
  529. self.save_wave(mixed_audio, filename)
  530. return

stimulus_generation.py at commit 026e5b7, under MIT · at the source

Overview

Authors: Janna Steinebach1, Tobias Reichenbach1
ORCID iDs: Janna Steinebach
  1. Department Artificial Intelligence in Biomedical Engineering, Friedrich-Alexander-Universität Erlangen-Nürnberg, Erlangen, Germany
Journal: Frontiers in neuroscience, volume 20, article 1756386
Dates: received 28 November 2025; accepted 4 February 2026; published online 2 March 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.3389/fnins.2026.1756386 · PMID 41847227 · PMCID PMC12989502 · OpenAlex W7133229558
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: human (organism), cognitive (subfield)
Methods: Spectral & time-frequency, Statistics, Machine learning, Preprocessing
Keywords: auditory attention, distortion products, efferent feedback, inner ear biology, MOC system, otoacoustic emissions, speech processing
Topic: Hearing, Cochlea, Tinnitus, Genetics (Sensory Systems, Neuroscience), according to OpenAlex
Citations: not cited yet (Europe PMC); 66 references in the paper

Abstract

Humans are remarkably skilled at understanding speech in noisy environments. While segregation of different audio streams is mostly accomplished in the auditory cortex, neural feedback connections run from the cortex to the brainstem and to the cochlea. The latter organ not only houses the mechanosensitive hair cells, but also possesses an active process enabling it to amplify sound in a frequency-dependent manner. A physiological correlate of the active process are distortion-product otoacoustic emissions (DPOAEs) that can be measured non-invasively from the ear canal. Here we employed speech-like DPOAEs, measured in response to stimuli derived from natural human speech and thus reflecting the harmonic spectral structure of voiced speech. We show that these emissions are modulated by selective attention to one of two competing voices, as well as by intermodal attention. Specifically, speech-like DPOAEs evoked by stimuli related to resolved harmonics of a voice were significantly reduced when that voice was attended compared to when it was ignored. No such effect was observed for stimuli related to unresolved harmonics of the target voice when the competing voice's harmonics in that range were unresolved as well, indicating that attentional modulation is specific to those components of voiced speech that are spectrally resolved. Our findings support the hypothesis that the cochlea's active process already shapes selective attention to speech in noise. Moreover, the speech-like DPOAEs that we developed open up further possibilities for investigating the contribution of the cochlear active process to auditory scene analysis in naturalistic settings.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repository

Its files are read in the Code ↔ Paper reader above, with 4 matches between paragraphs and lines of code.

janna-stb/dpoae_attention_study

License: MIT
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: 026e5b711d983c50894d168c83b8fadc4f918fa5, 1 December 2025
Languages: Python (12)
Size: 15 files, 12 scripts
Software Heritage: not archived
Found in: “Data availability statement”
Holds: README, license file, CITATION.cff
Not found: environment file, tests, continuous integration, documentation
Tools: NumPy (7 files), Matplotlib (5 files), pandas (4 files), SciPy (4 files), seaborn (1 file), statsmodels (1 file)
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
14 files

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 12 scripts, each with its path and the digest of its content;
  • 4 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Data availability statement

All custom code used for stimulus generation, speech-like DPOAE analysis via cross-correlation, and statistical evaluation is openly available on GitHub at https://github.com/janna-stb/dpoae_attention_study.git under the MIT License.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 30 September 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 2 authors, 7 keywords, 1 funder, 60 references.

Cite

This paper

Steinebach, J., & Reichenbach, T. (2026). Attention to speech modulates distortion product otoacoustic emissions evoked by speech-derived stimuli in humans. Frontiers in neuroscience, 20, 1756386. https://doi.org/10.3389/fnins.2026.1756386

BibTeX

@article{steinebach2026attention,
author = {Steinebach, Janna and Reichenbach, Tobias},
title = {{Attention to speech modulates distortion product otoacoustic emissions evoked by speech-derived stimuli in humans}},
journal = {Frontiers in neuroscience},
year = {2026},
month = mar,
volume = {20},
pages = {1756386},
publisher = {Frontiers Media SA},
issn = {1662-4548},
doi = {10.3389/fnins.2026.1756386},
url = {https://doi.org/10.3389/fnins.2026.1756386},
pmid = {41847227},
pmcid = {PMC12989502}
}

RIS

TY - JOUR
AU - Steinebach, Janna
AU - Reichenbach, Tobias
TI - Attention to speech modulates distortion product otoacoustic emissions evoked by speech-derived stimuli in humans
T2 - Frontiers in neuroscience
J2 - Front Neurosci
PY - 2026
DA - 2026/03/02
VL - 20
SP - 1756386
SN - 1662-4548
PB - Frontiers Media SA
DO - 10.3389/fnins.2026.1756386
UR - https://doi.org/10.3389/fnins.2026.1756386
LA - en
ER -

CSL-JSON

{
"id": "10.3389/fnins.2026.1756386",
"type": "article-journal",
"title": "Attention to speech modulates distortion product otoacoustic emissions evoked by speech-derived stimuli in humans",
"container-title": "Frontiers in neuroscience",
"author": [
{
"family": "Steinebach",
"given": "Janna"
},
{
"family": "Reichenbach",
"given": "Tobias"
}
],
"container-title-short": "Front Neurosci",
"volume": "20",
"page": "1756386",
"DOI": "10.3389/fnins.2026.1756386",
"PMID": "41847227",
"PMCID": "PMC12989502",
"ISSN": "1662-4548",
"publisher": "Frontiers Media SA",
"URL": "https://doi.org/10.3389/fnins.2026.1756386",
"language": "en",
"issued": {
"date-parts": [
[
2026,
3,
2
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1038/s41562-026-02414-7 [code]
Optimized feature gains explain and predict successes and failures of human selective listening.
Journal: Nature human behaviour
In common: statsmodels, seaborn, pandas, 3 other tools, cognitive, 4 references
[2] doi:10.7554/elife.100056 [code]
Multi-talker speech comprehension at different temporal scales in listeners with normal and impaired hearing.
Journal: eLife
In common: pandas, SciPy, NumPy, cognitive, 4 references
[3] doi:10.1523/eneuro.0041-26.2026 [code]
Ocular Speech Tracking Persists in Blindness, but Its Dynamics and Oculo-Cerebral Connectivity Depend on Visual Status.
Journal: eNeuro
In common: statsmodels, seaborn, pandas, 3 other tools, cognitive, 1 reference
[4] doi:10.1111/psyp.70397 [code]
Cortical Contributions to Attentional Orienting and Response Cancellation in Action Stopping.
Journal: Psychophysiology
In common: statsmodels, seaborn, pandas, 3 other tools, cognitive, 1 reference
[5] doi:10.1016/j.ibneur.2026.05.013 [code]
Anticipatory slow potentials before auditory feedback show posterior predominance but limited condition effects in speech-in-noise.
Journal: IBRO neuroscience reports
In common: statsmodels, pandas, SciPy, 2 other tools, cognitive, 1 reference
[6] doi:10.1162/imag.a.1286 [code]
Behavioral imitation with artificial neural networks leads to personalized models of brain dynamics during videogame play.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: statsmodels, seaborn, pandas, 3 other tools, 1 reference
[7] doi:10.1162/imag.a.1256 [code]
Gamer in the scanner: Event-related analysis of fMRI activity during retro videogame play guided by automated annotations of game content.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: statsmodels, seaborn, pandas, 3 other tools, 1 reference
[8] doi:10.34133/csbj.0042 [code]
Using Steady-State Visual Evoked Potentials to Characterize Wide-Ranging Retinopathy Linked to &lt;i&gt;CRB1&lt;/i&gt;: Implications for Clinical Trials.
Journal: Computational and structural biotechnology journal
In common: statsmodels, seaborn, pandas, 3 other tools, 1 reference
[9] doi:10.1117/1.nph.13.2.025001 [code]
Surface-based image reconstruction optimization for high-density functional near-infrared spectroscopy.
Journal: Neurophotonics
In common: statsmodels, seaborn, pandas, 3 other tools, 1 reference
[10] doi:10.1073/pnas.2536563123 [code]
Temporally structured motor and auditory representations in covert syllable production.
Journal: Proceedings of the National Academy of Sciences of the United States of America
In common: statsmodels, pandas, SciPy, 2 other tools, cognitive, 1 reference

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.