This is a web app used by researchers from the CNRS (French National Center for Scientific Research) of Marseille, France.
Its goal is to collect some data regarding hearing conditions such as misophonia and hyperacusis.
This project requires the nvm program in order to work. If you are a Linux or Mac OS user, please install this program.
If you are a Windows user, the best alternative to nvm
is to install the latest LTS version of Node available on nodejs.org. On the homepage, click on the green button labelled "Recommended for Most Users". The LTS version when writing these lines is 12.18.1
.
⚠️ For Linux / Mac OS users only: oncenvm
installed, use the following command at the root of the project to use the appropriate Node version:nvm useIf the required Node version is not installed, the output of
nvm use
will be an "error" message telling you to install it via a command, such asnvm install 12
for example.
It is recommended to use VS Code as editor to make changes to this project.
You can install the required dependencies for this project with the following command:
npm ci
If you want to run the app, you can use the following command:
npm start
Then open a new tab on your favorite browser at the following location: http://localhost:1234/index.html.
This app doesn't need a server in order to work. However, it needs a serverless function to collect the results at the end of the experiment, and store them somewhere (e.g. attachment file to an email).
It's using Netlify functions in production, but in order to run the function on your machine, you can use the following command (on a different terminal session/window preferably):
npm run lambda:build && npm run lambda:start
⚠️ The function won't work on your machine as is because it requires a few environment variables used to connect to the Sendgrid service, which allows the function to send the results via email. You can create asendgrid.env
file at the root of the project containing the following environment variables:export SENDGRID_API_KEY='api key from Sendgrid' export SENDGRID_FROM_EMAIL='whitelisted sender email' export SENDGRID_FROM_NAME='Echo' export SENDGRID_TO_EMAIL='target email address that will receive the results'If you are a maintainer of the project, please contact ruizb to get the production values for these environment variables.
src/functions
directory: you can't import files from outside src/functions
. For example, importing a file from src/client
inside src/functions/collect-results.ts
won't work:
// src/functions/collect-results.ts
import { UserInfo } from '../client/models/userInfo'
const foo = (userInfo: UserInfo): void => {}
This is why we have (re)declared a more permissive UserInfo
interface in the src/functions/models
directory.
You can use the following command to run all the unit tests:
npm test
These tests ensure that some pieces of logic from the app work as expected, such as the "random sounds selection" algorithm, or the CSV generation for the attachment file of the email containing the results.
You need an access to the project on Netlify in order to deploy the changes from the master
branch to the users.
A reset of the whole experiment - for a given user - is available on /reset.html
. For example, on your machine, you can go to http://localhost:1234/reset.html to completely restart the app.
The majority of the text is available in the index.html
file. You can change the existing text or add new paragraphs as you wish.
You can see an example that adds a new paragraph to the first screen here.
If you wish to change the sounds used during the experiment, let it be update an existing one, adding a new one or deleting one, you can follow these steps:
-
Make your changes regarding the sounds in the
src/client/sounds
directory. -
Open the
audioFilePath
file, then:-
If you changed an existing sound, as long as the name is exactly the same (text case matters), you don't have to do anything special here.
-
If you added a new sound, you must first import this sound by adding a new import at the top of the file, e.g.:
import whiteNoise from '../sounds/White Noise_1.wav' + import myNewSound from '../sounds/My New Sound.wav'
Then, you need to add the imported sound to the
audioFilePaths
list, e.g. at the end of it:const audioFilePaths = [ birds, blowingNose, ..., wheezing, + myNewSound ]
🎥 Demonstration available here.
-
If you deleted a sound, you have to remove both the import line of this sound, and the imported "variable" from the
audioFilePaths
list. For example, if I choose to delete the "birds" sound, then I have to:- Remove this line from the
audioFilePath.ts
module:- import birds from '../sounds/Birds_1.wav' import blowingNose from '../sounds/Blowing_nose1.wav'
- Remove this line from the
audioFilePaths
list of this module:const audioFilePaths = [ - birds, blowingNose, ...
- Remove this line from the
-
First, you can add a new option to the listening device section in the index.html
file:
<div class="grouped fields">
<label>Qu'utilisez-vous pour écouter les sons de cette expérience ?</label>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" id="user-info_device-headset" value="headset" name="user-info_device" checked="" tabindex="0" class="hidden">
<label for="user-info_device-headset">Casque</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" id="user-info_device-earphones" value="earphones" name="user-info_device" tabindex="0" class="hidden">
<label for="user-info_device-earphones">Écouteurs</label>
</div>
</div>
+ <div class="field">
+ <div class="ui radio checkbox">
+ <input type="radio" id="user-info_device-speakers" value="speakers" name="user-info_device" tabindex="0" class="hidden">
+ <label for="user-info_device-speakers">Hauts-parleurs</label>
+ </div>
+ </div>
</div>
🎥 Demonstration available here.
Make sure to use a different id
, value
and for
values matching the new listening device. Here, we chose speakers
as the new available option.
Next, you need to update the ListeningDevice
enum, as long as the isValidDevice
function in the userInfo.ts
module:
const enum ListeningDevice {
HeadSet = 'headset',
EarPhones = 'earphones',
+ Speakers = 'speakers'
}
The value of this new entry must match the value you chose for the value="speakers"
field earlier in the index.html
file.
const isValidDevice = (device: unknown): device is ListeningDevice =>
isString(device) &&
([
ListeningDevice.HeadSet,
ListeningDevice.EarPhones,
+ ListeningDevice.Speakers
] as string[]).indexOf(device) >= 0
🎥 Demonstration available here.
💡 It's best to use a select input instead of radio buttons when there are more than 4-5 choices.
Adding a new field to the first form requires a few steps.
The first thing to do is to add the HTML elements into src/client/index.html
to expose this new field to the user. To do that, you can follow the example right below, where we add a new "ice cream" field.
<div class="grouped fields">
<label>Avez-vous des acouphènes ? <i data-content="Sifflement d'oreille." class="info circle icon"></i></label>
...
</div>
+ <div class="grouped fields">
+ <label>Aimez-vous les glaces ?</label>
+ <div class="field">
+ <div class="ui radio checkbox">
+ <input type="radio" id="user-info_icecream-yes" value="yes" name="user-info_icecream" tabindex="0" class="hidden">
+ <label for="user-info_icecream-yes">Oui</label>
+ </div>
+ </div>
+ <div class="field">
+ <div class="ui radio checkbox">
+ <input type="radio" id="user-info_icecream-no" value="no" name="user-info_icecream" tabindex="0" class="hidden">
+ <label for="user-info_icecream-no">Non</label>
+ </div>
+ </div>
+ <div class="field">
+ <div class="ui radio checkbox">
+ <input type="radio" id="user-info_icecream-unknown" value="unknown" name="user-info_icecream" checked="" tabindex="0" class="hidden">
+ <label for="user-info_icecream-unknown">Je ne sais pas</label>
+ </div>
+ </div>
+ </div>
<div class="grouped fields">
<label>Avez-vous une hypersensibilité auditive ? <i data-content="Les sons vous semblent-ils plus forts ou plus gênants que la normale ?" class="info circle icon"></i></label>
...
</div>
🎥 Demonstration available here.
Now that the view/template is available, we need to gather the user's answer: in other words, get the value provided by the user via the input element he filled in. This is where we need to make a few changes in the TypeScript files:
-
-
Add a new property to the
UserInfo
interface:export interface UserInfo { age: number device: ListeningDevice hearingIssues: TriState tinnitus: TriState + iceCream: TriState hearingHypersensitivity: TriState soundsReactions: TriState soundsList: string[] }
-
Add a new verification step to the
isValidUserInfo
function:export const isValidUserInfo = (userInfo: unknown): userInfo is UserInfo => isNull(userInfo) || (isObject(userInfo) && hasOwnProperty(userInfo, 'age') && isNumber(userInfo.age) && hasOwnProperty(userInfo, 'device') && isValidDevice(userInfo.device) && hasOwnProperty(userInfo, 'hearingIssues') && isValidTriState(userInfo.hearingIssues) && hasOwnProperty(userInfo, 'tinnitus') && isValidTriState(userInfo.tinnitus) && + hasOwnProperty(userInfo, 'tinnitus') && + isValidTriState(userInfo.tinnitus) && hasOwnProperty(userInfo, 'hearingHypersensitivity') && isValidTriState(userInfo.hearingHypersensitivity) && hasOwnProperty(userInfo, 'soundsReactions') && isValidTriState(userInfo.soundsReactions) && (userInfo.soundsReactions ? hasOwnProperty(userInfo, 'soundsList') && isArray(userInfo.soundsList) : true))
🎥 Demonstration available here.
-
-
src/client/views/userInfoForm.ts
-
Add a new property to the
elements
object:const elements = { ..., tinnitus: () => document.querySelector( 'input[name="user-info_tinnitus"]:checked' ) as HTMLInputElement, + iceCream: () => + document.querySelector( + 'input[name="user-info_icecream"]:checked' + ) as HTMLInputElement, hypersensitivity: () => document.querySelector( 'input[name="user-info_hypersensitivity"]:checked' ) as HTMLInputElement, ... }
-
Add a new property to the
userInfo
object in thehandleUserInfoForm
function:const userInfo: UserInfo = { age: parseInt(elements.age.value, 10), device: elements.device.value as ListeningDevice, hearingIssues: elements.hearingIssues().value as TriState, tinnitus: elements.tinnitus().value as TriState, + iceCream: elements.iceCream().value as TriState, hearingHypersensitivity: elements.hypersensitivity().value as TriState, soundsReactions, soundsList: soundsReactions ? elements.soundsReactionsList.value.split(',').map(_ => _.trim()) : [] }
🎥 Demonstration available here.
-
-
src/functions/models/userInfo.ts
Add a new property to the
UserInfo
interface from thesrc/functions/models
directory:export interface UserInfo { age: number device: string hearingIssues: string tinnitus: string + iceCream: string hearingHypersensitivity: string soundsReactions: string soundsList?: string[] }
🎥 Demonstration available here.
-
src/functions/helpers/transformResultsToCsv.ts
Extract the new property from the
UserInfo
parameter, and add a new row in the generated CSV:const generateUserInfoCsv = ( { age, device, hearingIssues, tinnitus, + iceCream, hearingHypersensitivity, soundsReactions, soundsList }: UserInfo, soundVolume: number ): DestructuredCsv => [ ['user-info-label', 'user-info-value'], ['age', age.toString()], ['device', device], ['hearing-issues', hearingIssues], ['tinnitus', tinnitus], + ['icecream', iceCream], ['hearing-hypersens', hearingHypersensitivity], ['sounds-reactions', soundsReactions], ['sounds-list', (soundsList ?? []).join('/')], ['sound-volume', soundVolume.toString()] ]
🎥 Demonstration available here.
-
src/functions/helpers/transformResultsToCsv.test.ts
Update the
userInfo
object with the new property, as well as the CSV string expectations from the unit tests:const userInfo: UserInfo = { age: 28, device: ListeningDevice.HeadSet, hearingIssues: 'no', tinnitus: 'no', + iceCream: 'no', hearingHypersensitivity: 'no', soundsReactions: 'no', soundsList: [] }
.toEqual(`user-info-label,user-info-value,noise-tolerance-label,noise-tolerance-value,filename,score1,score2,score3 age,28,,,,,, device,headset,,,,,, hearing-issues,no,,,,,, tinnitus,no,,,,,, + icecream,no,,,,,, hearing-hypersens,no,,,,,, sounds-reactions,no,,,,,, sounds-list,,,,,,, sound-volume,0.31,,,,,, ,,statement-1,1,,,, ,,statement-2,2,,,, ,,statement-3,1,,,, ,,,,Birds_1.wav,33,35,31 ,,,,Blowing_nose1.wav,76,68,73 ,,,,Boire.wav,66,55,59`) })
🎥 Demonstration available here.
-
Finally, make sure that your changes didn't break anything by running
npm test
.🎥 Demonstration available here.
If you wish to add a new statement to the "noise tolerance" form, you can add it to the statement
list from the noiseToleranceForm.ts
file:
const statements = [
+ `Les glaces me rendent heureux.`,
`Certains sons me dérangent tellement que j’ai du mal à contrôler mes émotions.`,
`Les sons déplaisants me donnent l’impression d’être submergé(e).`,
`Je deviens anxieux à la simple pensée d’un son désagréable.`,
...
]
🎥 Demonstration available here.
ℹ️ Regarding the tech stack used for this project: I tried to keep it simple, with as fewer dependencies to libraries/frameworks as possible. I intentionally didn't use a component-based library such as React in order to make changes to the source code as easy as possible for researchers. This project uses:
- TypeScript: for the safety net provided with its type system
- Parcel: for bundling all the files (HTML, CSS, TypeScript to JavaScript, sound files...) into a production-ready app
- Semantic UI: for the CSS (i.e. design) and some user interactions
- Netlify lambda: for installing/building/serving Netlify functions, on your machine
- Jest: for writing and running unit tests