/echo

Online experiment re: misophonia and hyperacusis hearing conditions.

Primary LanguageJavaScriptMIT LicenseMIT

Echo

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.

Getting started

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: once nvm installed, use the following command at the root of the project to use the appropriate Node version:

nvm use

If 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 as nvm install 12 for example.

It is recommended to use VS Code as editor to make changes to this project.

Install the dependencies

You can install the required dependencies for this project with the following command:

npm ci

Run the app

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.

Run the lambda function(s)

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 a sendgrid.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.

⚠️ A note regarding files from the 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.

Run the tests

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.

Deploy the app

You need an access to the project on Netlify in order to deploy the changes from the master branch to the users.

Use cases

Reset/Restart the experiment

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.

Use cases

Change the text

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.

Change the sounds

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:

  1. Make your changes regarding the sounds in the src/client/sounds directory.

  2. 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:

      1. Remove this line from the audioFilePath.ts module:
        - import birds from '../sounds/Birds_1.wav'
        import blowingNose from '../sounds/Blowing_nose1.wav'
      2. Remove this line from the audioFilePaths list of this module:
        const audioFilePaths = [
        -     birds,
            blowingNose,
            ...

Change the user information form

Adding a new listening device

HTML

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.

TypeScript

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

Adding a new field to the first form requires a few steps.

HTML

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.

TypeScript

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:

  • src/models/userInfo.ts

    • 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 the handleUserInfoForm 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 the src/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.

Change the noise tolerance form

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