inst/shiny/formant_app/server.R

# formant_app()
#
# To do: box for selecting desired pitch for synthesis; deleting a formant in the button also deletes the annotation (bug); maybe an option to have log-spectrogram and log-spectrum (a bit tricky b/c all layers have to be adjusted); maybe remove the buggy feature of editing formant freq in the button as text, just display current value there (but then how to make it NA?); LPC saves all avail formants - check beh when changing nFormants across annotations & files; from-to in play sometimes weird (stops audio while cursor is still moving); highlight smts disappears in ann_table (buggy! tricky!); load audio upon session start; maybe arbitrary number of annotation tiers

# Start with a fresh R session and run the command options(shiny.reactlog=TRUE)
# Then run your app in a show case mode: runApp('inst/shiny/formant_app', display.mode = "showcase")
# At any time you can hit Ctrl+F3 (or for Mac users, Command+F3) in your web browser to launch the reactive log visualization.


# # tip: to read the output, do smth like:
# a = read.csv('~/Downloads/output.csv', stringsAsFactors = FALSE)
# as.numeric(unlist(strsplit(a$pitch, ',')))

# shinyBS needs to be included as a dependency (instead of just "import"):
# see https://stackoverflow.com/questions/52649138/including-shinybs-in-a-package

server = function(input, output, session) {
  # make overlaid plots resizable (js fix)
  shinyjs::js$inheritSize(parentDiv = 'specDiv')

  # set max upload file size to 30 MB
  options(shiny.maxRequestSize = 30 * 1024 ^ 2)

  myPars = reactiveValues(
    print = FALSE,          # if TRUE, some functions print a meassage to the console when called
    debugQn = FALSE,            # for debugging - click "?" to step into the code
    zoomFactor = 2,         # zoom buttons change time zoom by this factor
    zoomFactor_freq = 1.5,  # same for frequency
    drawSpec = TRUE,
    shinyTip_show = 1000,      # delay until showing a tip (ms)
    shinyTip_hide = 0,         # delay until hiding a tip (ms)
    initDur = 2000,            # initial duration to analyze (ms)
    spec_xlim = c(0, 2000),
    out_fTracks = list(),      # a list for storing formant tracks per file
    out_spects = list(),       # a list for storing spectrograms
    selectedF = 'F1',          # preselect F1 for correction
    slider_ms = 50,            # how often to update play slider
    scrollFactor = .75,        # how far to scroll on arrow press/click
    wheelScrollFactor = .1,    # how far to scroll on mouse wheel (prop of xlim)
    samplingRate_idx = 1,      # sampling rate scaling index for playback
    listen_alphanum = TRUE,    # enable/disable alphanumeric hotkeys
    listen_enter = FALSE,      # enable/disable ENTER to close modal (new annotation)
    listen_enter_edit = FALSE, # ENTER to edit an existing annotation
    cursor = 0,
    listenToFbtn = FALSE,      # buggy
    play = list(on = FALSE)
  )

  # NB: using myPars$play$cursor for some reason invalidates the observer,
  # so it keeps executing as fast as it can - no idea why!

  # clean-up of www/ folder: remove all files except temp.wav
  # if (!dir.exists("www")) dir.create("www")  # otherwise trouble with shinyapps.io
  if (file.exists('www/temp.csv')) {
    showModal(modalDialog(
      title = "Unsaved data",
      "Found unsaved data from a prevous session. Append to the new output?",
      easyClose = TRUE,
      footer = tagList(
        actionButton("discard", "Discard"),
        actionButton("append", "Append")
      )
    ))
  }
  observeEvent(input$discard, {
    file.remove('www/temp.csv')
    removeModal()
  })
  observeEvent(input$append, {
    myPars$out = try(read.csv('www/temp.csv', stringsAsFactors = FALSE))
    myPars$out = unique(myPars$out)  # remove duplicate rows
    removeModal()
  })

  files = list.files('www/', pattern = '.wav')
  for (f in files){
    file.remove(paste0('www/', f))
  }

  reset = function() {
    if (myPars$print) print('Resetting...')
    myPars$ann = NULL         # a dataframe of annotations for the current file
    myPars$currentAnn = NULL  # the idx of currently selected annotation
    myPars$bp = NULL          # selected points (under brush)
    myPars$spec = NULL
    myPars$spec_trimmed = NULL
    myPars$formantTracks = NULL
    myPars$formants = NULL
    myPars$analyzedUpTo = NULL
    myPars$selection = NULL
    myPars$cursor = 0
    myPars$spectrogram_brush = NULL
  }

  resetSliders = function() {
    if (myPars$print) print('Resetting sliders...')
    sliders_to_reset = names(input)[which(names(input) %in% rownames(def_form))]
    for (v in sliders_to_reset) {
      new_value = def_form[v, 'default']
      try(updateSliderInput(session, v, value = new_value))
      try(updateNumericInput(session, v, value = new_value))
      updateSelectInput(session, 'wn', selected = 'gaussian')
      updateSelectInput(session, 'wn_lpc', selected = 'gaussian')
      updateSliderInput(session, 'spec_ylim',
                        value = c(0, def_form['spec_ylim','default']))
      updateSliderInput(session, 'spectrum_xlim',
                        value = c(0, def_form['spectrum_xlim','default']))
      updateRadioButtons(session, 'spec_colorTheme', selected='bw')
      updateSelectInput(session, 'osc', selected = 'linear')
      updateSelectInput(session, 'vtl_method', selected = 'regression')
      updateSliderInput(session, 'speedSound', value = '35400')
      updateTextInput(session, 'coeffs', value = '')
      updateCheckboxInput(session, 'interceptZero', value = TRUE)
      updateCheckboxInput(session, 'tube', value = 'closed-open')
    }
  }
  observeEvent(input$reset_to_def, resetSliders())

  loadAudio = function() {
    # shinyjs::js$inheritSize(parentDiv = 'specDiv')
    if (myPars$print) print('Loading audio...')
    done()
    reset()  # also triggers done(), but done() needs to run first in case loadAudio
    # is re-executed (need to save myPars$ann --> myPars$out)

    # if there is a csv among the uploaded files, use the annotations in it
    ext = substr(input$loadAudio$name,
                 (nchar(input$loadAudio$name) - 2),
                 nchar(input$loadAudio$name))
    old_out_idx = which(ext == 'csv')[1]  # grab the first csv, if any
    if (!is.na(old_out_idx)) {
      user_ann = read.csv(input$loadAudio$datapath[old_out_idx], stringsAsFactors = FALSE)
      oblig_cols = c('file', 'from', 'to')
      if (nrow(user_ann) > 0 &
          !any(!oblig_cols %in% colnames(user_ann))) {
        idx_missing = which(apply(user_ann[, oblig_cols], 1, function(x) any(is.na(x))))
        if (length(idx_missing) > 0) user_ann = user_ann[-idx_missing, ]
        need_cols = c('label', 'dF', 'vtl', myPars$ff)
        missing_cols = need_cols[which(!need_cols %in% colnames(user_ann))]
        if (length(missing_cols) > 0) user_ann[, missing_cols] = NA
        user_ann = user_ann[, c(oblig_cols, need_cols)]
        if (nrow(user_ann) > 0) {
          if (is.null(myPars$out)) {
            myPars$out = user_ann
          } else {
            myPars$out = soundgen:::rbind_fill(myPars$out, user_ann)
            # remove duplicate rows
            myPars$out = unique(myPars$out)
          }
        }
      }
    }

    # work only with audio files
    idx_audio = which(apply(matrix(input$loadAudio$type), 1, function(x) {
      grepl('audio', x, fixed = TRUE)
    }))
    if (length(idx_audio) > 0) {
      if (is.null(myPars$fileList)) {
        myPars$fileList = input$loadAudio[idx_audio, ]
        myPars$n = 1   # file number in queue
      } else {
        sameFiles = which(myPars$fileList$name %in% input$loadAudio$name)
        if (length(sameFiles) > 0) {
          message('Note: uploading the same audio file twice overwrites previous annotations')
          if (!is.null(myPars$out)) {
            myPars$out = myPars$out[!myPars$out$file %in% myPars$fileList$name[sameFiles]]
            if (length(myPars$out) == 0) myPars$out = NULL
          }
          myPars$fileList = myPars$fileList[-sameFiles, ]
        }
        myPars$n = nrow(myPars$fileList) + 1
        myPars$fileList = rbind(myPars$fileList, input$loadAudio[idx_audio, ])
      }
      myPars$nFiles = nrow(myPars$fileList)  # number of uploaded files in queue
      myPars$drawSpec = FALSE  # hold on plotting the spectrogram until after running analyze()
      choices = as.list(myPars$fileList$name)
      names(choices) = myPars$fileList$name
      if (input$fileList == myPars$fileList$name[myPars$n])
        readAudio(myPars$n)  # doesn't fire automatically if the same as before
      updateSelectInput(session, 'fileList',
                        choices = as.list(myPars$fileList$name),
                        selected = myPars$fileList$name[myPars$n])
    } else if(!is.na(old_out_idx)) {
      # only a new csv uploaded - just refresh the current file
      readAudio(myPars$n)
    }
  }
  observeEvent(input$loadAudio, loadAudio())

  observeEvent(input$showpanel, {
    if(input$showpanel == TRUE) {
      shinyjs::removeCssClass("Main", "col-sm-12")
      shinyjs::addCssClass("Main", "col-sm-8")
      shinyjs::show(id = "Sidebar")
      shinyjs::enable(id = "Sidebar")
    }
    else {
      shinyjs::removeCssClass("Main", "col-sm-8")
      shinyjs::addCssClass("Main", "col-sm-12")
      shinyjs::hide(id = "Sidebar")
    }
  })

  readAudio = function(i) {
    # reads an audio file with tuneR::readWave
    if (myPars$print) print('Reading audio...')
    temp = myPars$fileList[i, ]
    myPars$myAudio_filename = temp$name
    myPars$myAudio_path = temp$datapath
    myPars$myAudio_type = temp$type

    extension = substr(myPars$myAudio_filename,
                       nchar(myPars$myAudio_filename) - 2,
                       nchar(myPars$myAudio_filename))
    if (extension == 'wav' | extension == 'WAV') {
      myPars$temp_audio = tuneR::readWave(temp$datapath)
    } else if (extension == 'mp3' | extension == 'MP3') {
      myPars$temp_audio = tuneR::readMP3(temp$datapath)
    } else {
      warning('Input not recognized: must be a wav or mp3 file')
    }

    myPars$myAudio = as.numeric(myPars$temp_audio@left)
    myPars$ls = length(myPars$myAudio)
    myPars$samplingRate = myPars$temp_audio@samp.rate
    myPars$maxAmpl = 2 ^ (myPars$temp_audio@bit - 1)
    if (input$normalizeInput) {
      myPars$myAudio = myPars$myAudio / max(abs(myPars$myAudio)) * myPars$maxAmpl
    }
    myPars$nyquist = myPars$samplingRate / 2 / 1000
    # updateSliderInput(session, 'spec_ylim',
    #                   value = c(0, min(def_form['spec_ylim', 'default'], myPars$nyquist)),
    #                   max = myPars$nyquist)
    # updateSliderInput(session, 'spectrum_xlim',
    #                   value = c(0, min(def_form['spectrum_xlim', 'default'], myPars$nyquist)),
    #                   max = myPars$nyquist)
    myPars$dur = length(myPars$temp_audio@left) * 1000 / myPars$temp_audio@samp.rate
    myPars$myAudio_list = list(
      sound = myPars$myAudio,
      samplingRate = myPars$samplingRate,
      scale = myPars$maxAmpl,
      timeShift = 0,
      ls = length(myPars$myAudio),
      duration = myPars$dur / 1000
    )
    myPars$time = seq(1, myPars$dur, length.out = myPars$ls)
    myPars$spec_xlim = c(0, min(myPars$initDur, myPars$dur))
    if (!is.finite(myPars$spec_xlim[2])) browser()  # weird glitches
    myPars$regionToAnalyze = myPars$spec_xlim

    # shorten window and step if the input is very short
    max_win = round(myPars$dur / 2)
    if (input$windowLength > myPars$dur) {
      updateNumericInput(session, 'windowLength', value = max_win)
      updateNumericInput(session, 'step', value = max_win / 2)
    }
    if (input$windowLength_lpc > myPars$dur) {
      updateNumericInput(session, 'windowLength_lpc', value = max_win)
      updateNumericInput(session, 'step_lpc', value = max_win / 2)
    }

    # update info - file number ... out of ...
    updateSelectInput(session, 'fileList',
                      label = NULL,
                      selected = myPars$fileList$name[myPars$n])
    file_lab = paste0('File ', myPars$n, ' of ', myPars$nFiles)
    output$fileN = renderUI(HTML(file_lab))

    # if we've already worked with this file,
    # re-load the annotations and (if in current session) formant tracks + spec
    idx = which(myPars$out$file == myPars$myAudio_filename)
    if (length(idx) > 0) {
      myPars$ann = myPars$out[idx, ]
      myPars$currentAnn = 1
    } else {
      myPars$ann = NULL
    }
    if (!is.null(myPars$out_fTracks[[myPars$myAudio_filename]])) {
      myPars$formantTracks = myPars$out_fTracks[[myPars$myAudio_filename]]
      myPars$spec = myPars$out_specs[[myPars$myAudio_filename]]
      myPars$drawSpec = TRUE  # don't need to wait for analyze() to run
    }
    drawAnn()
    # drawAnnTbl()
  }

  extractSpectrogram = observe({
    # Instead of re-loading the file every time, could save the spectrogram
    # matrix and re-draw manually with soundgen:::filled.contour.mod
    if (!is.null(myPars$myAudio)) {  # & is.null(myPars$spec)
      if (myPars$print) print('Extracting spectrogram...')
      temp_spec = try(soundgen:::.spectrogram(
        myPars$myAudio_list,
        samplingRate = myPars$samplingRate,
        dynamicRange = input$dynamicRange,
        windowLength = input$windowLength,
        step = input$step,
        wn = input$wn,
        zp = 2 ^ input$zp,
        contrast = input$specContrast,
        brightness = input$specBrightness,
        blur = c(input$blur_freq, input$blur_time),
        specType = input$specType,
        output = 'all',
        plot = FALSE
      ))
      if (!inherits(temp_spec, 'try-error') &&
          length(temp_spec) > 0 &&
          is.matrix(temp_spec$processed))
        myPars$spec = temp_spec$processed
      myPars$reassigned = temp_spec$reassigned
    }
  })

  writeAudioFile = observeEvent(c(myPars$temp_audio, myPars$samplingRate_idx), {
    if (myPars$print) print('Writing audio file...')
    # Method: saves a temporary audio file in 'www/'. This is a workaround since
    # html tag for some reason cannot play myPars$myAudio_path (although feeding
    # it to spectrogram works - so probably only works within R). Alternatives:
    # soundgen::play() or shinyFiles library

    # first remove the previous sound file to avoid cluttering up the www/ folder
    if (!is.null(myPars$myfile)){
      file.remove(paste0('www/', myPars$myfile))
    }
    randomID = paste(sample(c(letters, 0:9), 8, replace = TRUE), collapse = '')
    myPars$myfile = paste0(randomID, '.wav')
    # this is the new sound file. NB: has to be saved in www/ !!!
    seewave::savewav(myPars$temp_audio,
                     f = myPars$samplingRate * myPars$samplingRate_idx,
                     filename = paste0('www/', myPars$myfile))
    output$htmlAudio = renderUI(
      tags$audio(src = myPars$myfile, type = myPars$myAudio_type,
                 id = 'myAudio',
                 style = "display: none;")
    )
  }, ignoreInit = TRUE)
  observeEvent(input$samplingRate_mult, {
    myPars$samplingRate_idx = 2 ^ input$samplingRate_mult
  })


  # update the number of formant buttons
  output$fButtons = renderUI({
    c(
      list(actionButton(
        inputId = 'remF',
        label = HTML("<img src='icons/minus.png' width = '25px'>"), # '-',
        class = "buttonInline"
      )),
      lapply(1:input$nFormants, function(f) {  # need to return a list, hence lapply
        tags$div(
          id = paste0('fDiv_', f),
          class = ifelse(f == 1, 'fBox selected', 'fBox'),
          textInput(
            inputId = paste0('F', f, '_text'),
            label = paste0('F', f),
            value = '')
        )
      }),
      list(actionButton(
        inputId = 'addF',
        label = HTML("<img src='icons/plus.png' width = '25px'>"), # '+',
        class = "buttonInline"
      )),
      list(actionButton(
        inputId = 'defaultFmtBtn',
        label = HTML("<img src='icons/update.png' width = '30px'>"),
        class = "buttonInline"
      )),
      list(
        shinyBS:::bsPopover(id = 'fDiv_1', title = NULL, content = 'Click to select, type a number to correct, any text or NA to treat as missing, empty to reset to default', placement = "right", trigger = "hover", options = list(delay = list(show = 1000, hide = 0))),
        shinyBS::bsPopover(id='remF', title = NULL, content = 'Remove one formant', placement = "right", trigger = "hover", options = list(delay = list(show = 1000, hide = 0))),
        shinyBS::bsPopover(id='addF', title = NULL, content = 'Add one formant', placement = "right", trigger = "hover", options = list(delay = list(show = 1000, hide = 0))),
        shinyBS::bsPopover(id='defaultFmtBtn', title = NULL, content = 'Reset formant values to defaults as measured', placement = "right", trigger = "hover", options = list(delay = list(show = 1000, hide = 0)))
      )
    )
  })

  observeEvent(input$addF, {
    updateSliderInput(session, 'nFormants',
                      value = input$nFormants + 1)
  })

  observeEvent(input$remF, {
    updateSliderInput(session, 'nFormants',
                      value = max(1, input$nFormants - 1))
  })

  # edit myPars$ann when formant freq is modified manually as text
  observeEvent(input$nFormants, {
    lapply(1:input$nFormants, function(f) {
      fn = paste0('F', f, '_text')
      observeEvent(input[[fn]], {
        hr()  # otherwise row highlight disappears, no idea why!
        if (isolate(myPars$listenToFbtn)) {
          v = input[[fn]]
          vn = suppressWarnings(as.numeric(v))
          if (v == '') {
            # empty string - reset to default
            myPars$ann[myPars$currentAnn, paste0('F', f)] = myPars$formants[f]
            updateTextInput(
              session, fn,
              value = as.character(myPars$formants[f]))
          } else if (!is.na(vn)) {
            # number - treat as manual correction
            myPars$ann[myPars$currentAnn, paste0('F', f)] = vn
          } else {
            # any other (invalid) input - treat as a missing formant (NA)
            myPars$ann[myPars$currentAnn, paste0('F', f)] = NA
          }
          updateVTL()
          hr()
        }
      })

      # add onclick event with shinyjs to select the formant to edit
      this_div = paste0('fDiv_', f)
      shinyjs::onclick(id = this_div, {
        myPars$selectedF = paste0('F', f)
        shinyjs::addCssClass(id = this_div, class = 'selected')
        other_divs = paste0('fDiv_', (1:input$nFormants)[-f])
        for (d in other_divs) {
          shinyjs::removeCssClass(id = d, class = 'selected')
        }
      })
      # start listening to formant buttons when one is in focus
      shinyjs::onevent('focusin', id = fn, {
        myPars$listenToFbtn = TRUE
        # print('LISTENING')
      })
    })
    avFmPerSel()
  })
  # focusout fires when it shouldn't, so we stop listening if the mouse is over
  # the main panel or the spectrum instead (ie basically anywhere else)
  shinyjs::onevent('hover', id = 'left', {
    myPars$listenToFbtn = FALSE
    # print('STOP LISTENING')
  })
  shinyjs::onevent('hover', id = 'spectrum', {
    myPars$listenToFbtn = FALSE
    # print('STOP LISTENING')
  })

  # Updating spec / osc stuff to speed up plotting
  observe({
    if (!is.null(myPars$myAudio)) {
      # if (myPars$print) print('Scaling audio...')
      if (input$osc == 'dB') {
        myPars$myAudio_scaled = osc(
          myPars$myAudio,
          dynamicRange = input$dynamicRange,
          dB = TRUE,
          maxAmpl = myPars$maxAmpl,
          plot = FALSE,
          returnWave = TRUE)
        myPars$ylim_osc = c(-2 * input$dynamicRange, 0)
      } else {
        myPars$myAudio_scaled = myPars$myAudio
        myPars$ylim_osc = c(-myPars$maxAmpl, myPars$maxAmpl)
      }
    }
  })

  observe({
    # Cut just the part of spec currently needed for plotting
    # (faster than plotting a huge matrix with xlim/ylim)
    if (!is.null(myPars$spec) & !is.null(myPars$myAudio_scaled)) {
      if (myPars$print) print('Trimming the spec & osc')
      # spec
      x = as.numeric(colnames(myPars$spec))
      idx_x = which(x >= (myPars$spec_xlim[1] / 1.05) &
                      x <= (myPars$spec_xlim[2] * 1.05))
      # 1.05 - a bit beyond b/c we use xlim/ylim and may get white space
      y = as.numeric(rownames(myPars$spec))
      idx_y = which(y >= (input$spec_ylim[1] / 1.05) &
                      y <= (input$spec_ylim[2] * 1.05))
      myPars$spec_trimmed = downsample_spec(
        myPars$spec[idx_y, idx_x],
        maxPoints = 10 ^ input$spec_maxPoints)
      # dim(myPars$spec_trimmed)

      # osc
      idx_s = max(1, (myPars$spec_xlim[1] / 1.05 * myPars$samplingRate / 1000)) :
        min(myPars$ls, (myPars$spec_xlim[2] * 1.05 * myPars$samplingRate / 1000))
      downs_osc = 10 ^ input$osc_maxPoints

      isolate({
        myPars$myAudio_trimmed = myPars$myAudio_scaled[idx_s]
        myPars$time_trimmed = myPars$time[idx_s]
        myPars$ls_trimmed = length(myPars$myAudio_trimmed)
        if (!is.null(myPars$myAudio_trimmed) &&
            myPars$ls_trimmed > downs_osc) {
          myseq = round(seq(1, myPars$ls_trimmed,
                            length.out = downs_osc))
          myPars$myAudio_trimmed = myPars$myAudio_trimmed[myseq]
          myPars$time_trimmed = myPars$time_trimmed[myseq]
          myPars$ls_trimmed = length(myseq)
        }
      })
      myPars$drawSpec = TRUE
    }
  })

  downsample_sound = function(x, maxPoints) {
    if (!is.null(myPars$myAudio_trimmed) &&
        myPars$ls_trimmed > (10 ^ input$osc_maxPoints)) {
      if (myPars$print) print('Downsampling osc')
      myseq = round(seq(1, myPars$ls_trimmed,
                        by = myPars$ls_trimmed / input$osc_maxPoints))
      myPars$myAudio_trimmed = myPars$myAudio_trimmed[myseq]
      myPars$ls_trimmed = length(myseq)
    }
  }

  downsample_spec = function(x, maxPoints) {
    lxy = nrow(x) * ncol(x)
    if (length(lxy) > 0 && lxy > maxPoints) {
      if (myPars$print) print('Downsampling spectrogram...')
      lx = ncol(x)  # time
      ly = nrow(x)  # freq
      downs = sqrt(lxy / maxPoints)
      seqx = round(seq(1, lx, length.out = lx / downs))
      seqy = round(seq(1, ly, length.out = ly / downs))
      out = x[seqy, seqx]
    } else {
      out = x
    }
    return(out)
  }

  observeEvent(c(myPars$spec, myPars$spec_xlim, myPars$analyzedUpTo), {
    if (!is.null(myPars$spec)) {
      if (is.null(myPars$analyzedUpTo)) {
        myPars$regionToAnalyze = myPars$spec_xlim
        call = TRUE
      } else {
        if (myPars$analyzedUpTo < myPars$spec_xlim[2]) {
          myPars$regionToAnalyze = c(myPars$analyzedUpTo, myPars$spec_xlim[2])
          if (diff(myPars$regionToAnalyze) < 500) {
            # too little new audio to analyze - extend
            myPars$regionToAnalyze[2] = min(
              myPars$regionToAnalyze[2] + 500, myPars$dur
            )
            if (diff(myPars$regionToAnalyze) < 500) {
              myPars$regionToAnalyze[1] = max(
                myPars$regionToAnalyze[2] - 500, 0
              )
            }
          }
          call = TRUE
        } else {
          call = FALSE
        }
      }
      if (call) extractFormants()
    }
  })

  observe({
    # keep track of the maximum number of formant ever analyzed
    # to make sure the output table has enough columns
    myPars$maxF = max(myPars$maxF,
                      input$nFormants,
                      length(colnames(myPars$ann)) - 6)  # just the formant columns
  })

  observeEvent(input$nFormants, {
    myPars$ff = paste0('F', 1:input$nFormants)
    if (!is.null(myPars$formantTracks)) {
      missingCols = myPars$ff[which(!myPars$ff %in% colnames(myPars$formantTracks))]
      if (length(missingCols > 0)) myPars$formantTracks[, missingCols] = NA
    }
    if (!is.null(myPars$ann)) {
      missingCols = myPars$ff[which(!myPars$ff %in% colnames(myPars$ann))]
      myPars$ann[, missingCols] = NA
    }
  })



  #################
  ### P L O T S ###
  #################

  ## SPECTROGRAM
  output$spectrogram = renderPlot({
    if (!is.null(myPars$spec) && myPars$drawSpec == TRUE) {
      if (myPars$print) print('Drawing spectrogram...')
      par(mar = c(0.2, 2, 0.5, 2))  # no need to save user's graphical par-s - revert to orig on exit
      if (is.null(myPars$spec)) {
        plot(1:10, type = 'n', bty = 'n', axes = FALSE, xlab = '', ylab = '')
        text(x = 5, y = 5,
             labels = 'Upload wav/mp3 file(s) to begin...\nSuggested max duration ~30 s')
      } else {
        if (input$specType != 'reassigned') {
          # rasterized spectrogram
          soundgen:::filled.contour.mod(
            x = as.numeric(colnames(myPars$spec_trimmed)),
            y = as.numeric(rownames(myPars$spec_trimmed)),
            z = t(myPars$spec_trimmed),
            levels = seq(0, 1, length = 30),
            color.palette = soundgen:::switchColorTheme(input$spec_colorTheme),
            log = '',
            yScale = 'linear',
            xlim = myPars$spec_xlim,
            xaxt = 'n',
            xaxs = 'i', xlab = '',
            ylab = '',
            main = '',
            ylim = input$spec_ylim
          )
        } else {
          # unrasterized reassigned spectrogram
          soundgen:::plotUnrasterized(
            myPars$reassigned,
            levels = seq(0, 1, length = 30),
            color.palette = soundgen:::switchColorTheme(input$spec_colorTheme),
            log = '',
            yScale = 'linear',
            xlim = myPars$spec_xlim,
            xaxt = 'n',
            xaxs = 'i', xlab = '',
            ylab = '',
            main = '',
            ylim = input$spec_ylim,
            cex = input$reass_cex
          )
        }

        # Add text label of file name
        ran_x = myPars$spec_xlim[2] - myPars$spec_xlim[1]
        ran_y = input$spec_ylim[2] - input$spec_ylim[1]
        text(x = myPars$spec_xlim[1] + ran_x * .01,
             y = input$spec_ylim[2] - ran_y * .01,
             labels = myPars$myAudio_filename,
             adj = c(0, 1))  # left, top
      }
    }
  })

  observe({
    myPars$specOver_opts = list(
      xlim = myPars$spec_xlim, ylim = input$spec_ylim,
      xaxs = "i", yaxs = "i",
      bty = 'n', xaxt = 'n', yaxt = 'n',
      xlab = '', ylab = '')
  })

  output$specOver = renderPlot({
    if (!is.null(myPars$spec)) {
      par(mar = c(0.2, 2, 0.5, 2))
      # bg=NA makes the image transparent

      # empty plot to enable hover/click events for the spectrogram underneath
      do.call(plot, c(list(
        x = myPars$spec_xlim,
        y = input$spec_ylim,
        type = 'n'),
        myPars$specOver_opts))

      # Add a rectangle showing the current annotation
      if (!is.null(myPars$currentAnn)) {
        isolate({  # from myPars$ann
          rect(
            xleft = myPars$ann$from[myPars$currentAnn],
            xright = myPars$ann$to[myPars$currentAnn],
            ybottom = input$spec_ylim[1],
            ytop = input$spec_ylim[2],
            col = rgb(.2, .2, .2, alpha = .25),
            border = NA
          )
        })
      }

      # Add a rectangle showing the selected region
      if (!is.null(myPars$spectrogram_brush)) {
        rect(
          xleft = myPars$spectrogram_brush$xmin,
          xright = myPars$spectrogram_brush$xmax,
          ybottom = input$spec_ylim[1],
          ytop = input$spec_ylim[2],
          col = rgb(.2, .2, .2, alpha = .15),
          border = NA
        )
      }

      # Add formant tracks
      if (!is.null(myPars$formantTracks)) {
        for (f in 2:(length(myPars$ff) + 1)) {
          points(myPars$formantTracks$time,
                 myPars$formantTracks[, f] / 1000,
                 pch = 16,
                 col = input$spec_col,
                 cex = input$spec_cex)
        }
      }

      # Add formant frequencies from current ann
      if (!is.null(myPars$currentAnn)) {
        ff = myPars$ann[myPars$currentAnn, myPars$ff]
        text(x = rep(myPars$ann$from[myPars$currentAnn], length(ff)),
             y = ff / 1000,
             labels = names(ff),
             adj = c(0, .5), col = 'green', cex = 1.5)
      }

      # grid and x/y labels on hover
      if (!is.null(myPars$spectrogram_hover)) {
        # horizontal line
        do.call(points, c(list(
          x = myPars$spec_xlim,
          y = rep(myPars$spectrogram_hover$y, 2),
          type = 'l', lty = 3),
          myPars$specOver_opts))
        # frequency label
        do.call(text, list(
          x = myPars$spec_xlim[1],
          y = myPars$spectrogram_hover$y,
          labels = myPars$spectrogram_hover$freq,
          adj = c(0, 0)))
        # vertical line
        do.call(points, list(
          x = rep(myPars$spectrogram_hover$x, 2),
          y = input$spec_ylim,
          type = 'l', lty = 3))
        # time label
        do.call(text, list(
          x = myPars$spectrogram_hover$x,
          y = input$spec_ylim[1] + .025 * diff(input$spec_ylim),
          labels = soundgen:::convert_sec_to_hms(myPars$spectrogram_hover$x / 1000, 3),
          adj = .5))
      }

    }
  }, bg = 'transparent')

  output$specSlider = renderPlot({
    if (!is.null(myPars$spec)) {
      par(mar = c(0.2, 2, 0.5, 2))
      # bg=NA makes the image transparent

      if (myPars$cursor == 0) {
        # just a transparent plot
        do.call(plot, c(list(
          x = 1, type = 'n'),
          myPars$specOver_opts))
      } else {
        # horizontal line at current play time
        do.call(plot, c(list(
          x = rep(myPars$cursor, 2),
          y = input$spec_ylim,
          type = 'l'),
          myPars$specOver_opts))
      }
    }
  }, bg = 'transparent')

  observeEvent(input$spectrogram_hover, {
    if (!is.null(input$spectrogram_hover) & !is.null(myPars$spec)) {
      myPars$spectrogram_hover = input$spectrogram_hover
      cursor_hz = myPars$spectrogram_hover$y * 1000
      cursor_notes = soundgen::HzToNotes(cursor_hz)
      myPars$spectrogram_hover$freq = paste0(
        round(myPars$spectrogram_hover$y * 1000), ' Hz (',
        cursor_notes, ')')
    }
  })

  shinyjs::onevent('mouseout', id = 'specOver', {
    # NB: more flexible and less mafan than juggling with the observer of
    # input$spectrogram_hover
    myPars$spectrogram_hover = NULL
    shinyjs::js$clearBrush(s = '_brush')
  } )

  observeEvent(input$spectrogram_click, {
    myPars$cursor = input$spectrogram_click$x
    if (!is.null(myPars$currentAnn)) {
      inside_sel = (myPars$ann$from[myPars$currentAnn] < input$spectrogram_click$x) &
        (myPars$ann$to[myPars$currentAnn] > input$spectrogram_click$x)
      if (inside_sel) {
        # update both myPars$ann and the corresponding formant button
        myPars$ann[myPars$currentAnn, myPars$selectedF] = round(input$spectrogram_click$y * 1000)
        updateTextInput(
          session, paste0(myPars$selectedF, '_text'),
          value = as.character(myPars$ann[myPars$currentAnn, myPars$selectedF]))
        updateVTL()
      } else {
        myPars$spectrogram_brush = NULL
        shinyjs::js$clearBrush(s = '_brush')
      }
    } else {
      myPars$spectrogram_brush = NULL
      shinyjs::js$clearBrush(s = '_brush')
    }
  })

  observeEvent(input$spectrogram_dblclick, {
    if (!is.null(myPars$spectrogram_brush)) {
      showModal(dataModal_new())
    }
  })

  observeEvent(input$spectrogram_brush, {
    myPars$spectrogram_brush = input$spectrogram_brush
  })


  ## OSCILLOGRAM
  observe({
    output$oscillogram = renderPlot({
      if (!is.null(myPars$myAudio_trimmed)) {
        if (myPars$print) print('Drawing osc...')
        par(mar = c(2, 2, 0, 2))
        plot(myPars$time_trimmed,
             myPars$myAudio_trimmed,
             type = 'l',
             xlim = myPars$spec_xlim,
             ylim = myPars$ylim_osc,
             axes = FALSE, xaxs = "i", yaxs = "i", bty = 'o',
             xlab = 'Time, ms',
             ylab = '')
        box()
        # axis(side = 1)
        time_location = axTicks(1)
        time_labels = soundgen:::convert_sec_to_hms(time_location / 1000, 3)
        axis(side = 1, at = time_location, labels = time_labels)
        if (input$osc == 'dB') {
          axis(side = 4, at = seq(0, input$dynamicRange, by = 10))
          mtext("dB", side = 2, line = 3)
        }
        abline(h = 0, lty = 2)
      }
    }, execOnResize = TRUE)
  })


  ## SPECTRUM
  output$spectrum = renderPlot({
    if (!is.null(myPars$spectrum)) {
      if (myPars$print) print('Drawing spectrum...')
      par(mar = c(2, 2, 0.5, 0))
      xlim = input$spectrum_xlim # xlim = c(0, myPars$spectrum_freq_range[2])
      ylim = c(
        myPars$spectrum_ampl_range[1],
        # leave extra room for formant labels
        myPars$spectrum_ampl_range[2] + 5
      )
      plot(myPars$spectrum$freq,
           myPars$spectrum$ampl,
           xlab = '', ylab = '',
           xlim = xlim,
           ylim = ylim,
           xaxs = "i",
           type = 'l')
      # # fill in the AUC if needed
      # polygon(c(myPars$spectrum$freq, xlim[2], xlim[1]),
      #         c(myPars$spectrum$ampl, ylim[1], ylim[1]),
      #         border = NA,
      #         col = rgb(.5, .5, .5, .15))
      spectrum_peaks()

      # add a vertical line and freq label on hover
      if (!is.null(myPars$spectrum_hover)) {
        abline(v = myPars$spectrum_hover$x, lty = 2)
        text(x = myPars$spectrum_hover$x,
             y = myPars$spectrum_ampl_range[1],
             labels = myPars$spectrum_hover$cursor,
             adj = c(0, 0))
        # show the nearest spectral peak
        text(x = myPars$spectrum_hover$pf,
             y = myPars$spectrum_hover$pa,
             labels = myPars$spectrum_hover$peak,
             adj = c(0, 0))
        points(x = myPars$spectrum_hover$pf,
               y = myPars$spectrum_hover$pa, pch = 16)
      }

      # plot formant frequencies, if any
      if (!is.null(myPars$ann) &&
          any(is.numeric(myPars$currentAnn))) {
        ff = as.numeric(myPars$ann[myPars$currentAnn, myPars$ff])
      } else if (!is.null(myPars$formants) &&
                 any(!is.na(as.numeric(myPars$formants)))) {
        ff = as.numeric(myPars$formants)
      } else {
        ff = numeric(0)
      }

      if (length(ff) > 0) {
        for (i in 1:length(ff)) {
          f = ff[i] / 1000
          if (is.numeric(f) & any(!is.na(f))) {
            idx_f = which.min(abs(myPars$spectrum$freq - f))
            text(
              x = f,
              y = myPars$spectrum$ampl[idx_f] + 2,  # +2 to avoid overlap with cross
              labels = paste0('F', i),
              adj = c(.5, 0)
            )
            segments(
              x0 = f, y0 = ylim[1] - .04 * diff(ylim),
              x1 = f, y1 = myPars$spectrum$ampl[idx_f],
              lty = 3, col = 'lightgreen'
            )
          }
        }
      }
    }
  })

  observe({
    if (!is.null(myPars$spec_trimmed)) {
      if (myPars$print) print('Extracting spectrum of selection...')
      if (!is.null(myPars$selection) && length(myPars$selection) > 0) {
        # take the spectrum of selection (annotated region) from raw audio
        myPars$spectrum = try(as.list(soundgen:::getSmoothSpectrum(
          sound = myPars$selection,
          samplingRate = myPars$samplingRate,
          len = input$spectrum_len * (myPars$samplingRate / 1000 / 2) / diff(input$spec_ylim),
          loessSpan = 10 ^ input$spectrum_smooth
        )))
        # note: len is corrected to ensure constant resolution no matter how
        # much we zoom in on a particular frequency region
        # if (inherits(myPars$spectrum, 'try-error')) browser()
        # myPars$spectrum = list(
        #     freq = as.numeric(rownames(myPars$spec)),
        #     ampl = rowMeans(myPars$spec_trimmed)
        # )
      } else {
        # just use the visible part of the spectrogram, but all freq bins
        # (so not the same as myPars$spec_trimmed)
        time_stamps = as.numeric(colnames(myPars$spec))
        idx = which(time_stamps >= myPars$spec_xlim[1] &
                      time_stamps <= myPars$spec_xlim[2])
        if (length(idx) > 1) {
          ampl = as.numeric(rowMeans(myPars$spec[, idx, drop = FALSE]))
          ampl[ampl == 0] = 1e-16  # avoid -Inf in log-spectrum
          spec_temp = list(
            freq = as.numeric(rownames(myPars$spec[, idx])),
            ampl = 20 * log10(ampl)
          )
          myPars$spectrum = as.list(soundgen:::getSmoothSpectrum(
            spectrum = spec_temp,
            len = input$spectrum_len * (myPars$samplingRate / 1000 / 2) / diff(input$spec_ylim),
            loessSpan = 10 ^ input$spectrum_smooth
          ))
        }
      }
      isolate({
        # myPars$spectrum_freq_range = try(range(myPars$spectrum$freq))
        idx = try(which(myPars$spectrum$freq < input$spec_ylim[2]))
        if (!inherits(idx, 'try-error')) {
          myPars$spectrum_ampl_range = range(myPars$spectrum$ampl[idx])
        }
      })
    }
  })

  spectrum_peaks = reactive({
    # find peaks in the spectrum
    if (!is.null(myPars$spectrum)) {
      if (myPars$print) print('Looking for spectral peaks...')
      sp_zoo = zoo::as.zoo(myPars$spectrum$ampl)
      temp = zoo::rollapply(sp_zoo,
                            width = 3,
                            align = 'center',
                            function(x) which.max(x) == 2)
      myPars$spectrum_peaks = zoo::index(temp)[zoo::coredata(temp)]
    }
  })

  observeEvent(input$spectrum_click, {
    # update both myPars$ann and the corresponding formant button
    if (!is.null(myPars$currentAnn)) {
      myPars$ann[myPars$currentAnn, myPars$selectedF] = round(input$spectrum_click$x * 1000)
      updateTextInput(
        session, paste0(myPars$selectedF, '_text'),
        value = as.character(myPars$ann[myPars$currentAnn, myPars$selectedF]))
      updateVTL()
    }
  })

  observeEvent(input$spectrum_dblclick, {
    # update both myPars$ann and the corresponding formant button
    if (!is.null(myPars$currentAnn)) {
      pf_idx = which.min(abs(input$spectrum_dblclick$x -
                               myPars$spectrum$freq[myPars$spectrum_peaks]))
      pf = myPars$spectrum$freq[myPars$spectrum_peaks[pf_idx]]
      myPars$ann[myPars$currentAnn, myPars$selectedF] = round(pf * 1000)
      updateTextInput(
        session, paste0(myPars$selectedF, '_text'),
        value = as.character(myPars$ann[myPars$currentAnn, myPars$selectedF]))
      updateVTL()
    }
  })

  observeEvent(input$spectrum_hover, {
    if (!is.null(myPars$spectrum) & !is.null(input$spectrum_hover)) {
      myPars$spectrum_hover = data.frame(x = input$spectrum_hover$x,
                                         y = input$spectrum_hover$y)
      cursor_hz = round(input$spectrum_hover$x * 1000)
      cursor_notes = soundgen::HzToNotes(cursor_hz)
      myPars$spectrum_hover$cursor = paste0(cursor_hz, 'Hz (', cursor_notes, ')')

      pf_idx = which.min(abs(myPars$spectrum_hover$x -
                               myPars$spectrum$freq[myPars$spectrum_peaks]))
      myPars$spectrum_hover$pa =myPars$spectrum$ampl[myPars$spectrum_peaks[pf_idx]]
      myPars$spectrum_hover$pf = myPars$spectrum$freq[myPars$spectrum_peaks[pf_idx]]
      nearest_peak_hz = round(myPars$spectrum_hover$pf * 1000)
      nearest_peak_notes = soundgen::HzToNotes(nearest_peak_hz)
      myPars$spectrum_hover$peak = paste0(nearest_peak_hz, ' Hz (',
                                          nearest_peak_notes, ')')
    }
  })
  observeEvent(input$spectrum_smooth, {
    myPars$spectrum_hover = NULL
  })


  ## FORMANT SPACE
  output$fmtSpace = renderPlot({
    if (!is.null(myPars$currentAnn) &&
        !is.null(myPars$ann[myPars$currentAnn, ]) &&
        (is.finite(myPars$ann[myPars$currentAnn, ]$F1) &
         is.finite(myPars$ann[myPars$currentAnn, ]$F2))) {
      if (myPars$print) print('Drawing formant space')
      caf = as.numeric(myPars$ann[myPars$currentAnn, myPars$ff])
      if (input$fmtSpacePlot == 'vowelSpace') {
        cafr = schwa(formants = caf)$ff_relative_dF
        xlim = range(c(hillenbrand$F1Rel, cafr[1]))
        ylim = range(c(hillenbrand$F2Rel, cafr[2]))
        par(mar = c(0, 0, 0, 0))
        plot(hillenbrand$F1Rel, hillenbrand$F2Rel, type = 'n', xlab = '', ylab = '',
             xlim = xlim, ylim = ylim, bty = 'n', xaxt = 'n', yaxt = 'n')
        text(hillenbrand$F1Rel, hillenbrand$F2Rel,
             labels = hillenbrand$vowel, cex = 1.5, col = 'blue')
        points(cafr[1], cafr[2], pch = 4, cex = 2.5, col = 'red')
      } else if (input$fmtSpacePlot == 'regression') {
        if (input$vtl_method == 'regression') {
          soundgen:::getFormantDispersion(
            formants = caf,
            method = 'regression',
            interceptZero = input$interceptZero,
            tube = input$tube,
            speedSound = input$speedSound,
            plot = TRUE
          )
        } else {
          plot(1:10, type='n', bty='n', xlab='', ylab='', xaxt='n', yaxt='n')
          text(5, 9, label = 'Only VTL estimation with regression can be plotted here')
        }

      }
    } else {
      par(mar = c(0, 0, 0, 0))
      plot(hillenbrand$F1Rel, hillenbrand$F2Rel, type = 'n', xlab = '', ylab = '',
           bty = 'n', xaxt = 'n', yaxt = 'n')
      text(hillenbrand$F1Rel, hillenbrand$F2Rel,
           labels = hillenbrand$vowel, cex = 1.5, col = 'blue')
    }
  })


  ## ANNOTATIONS
  drawAnn = function() {
    output$ann_plot = renderPlot({
      if (myPars$print) print('Drawing annotations...')
      isolate({
        if (!is.null(myPars$ann)) {
          if (nrow(myPars$ann) > 0) {
            par(mar = c(0, 2, 0, 2))
            plot(myPars$time_trimmed,
                 xlim = myPars$spec_xlim,
                 ylim = c(.2, .8),
                 type = 'n',
                 xaxs = "i", yaxs = "i",
                 bty = 'n',
                 axes = FALSE,
                 xlab = '', ylab = ''
            )
            for (i in 1:nrow(myPars$ann)) {
              r = rnorm(1, 0, .05)  # random vertical shift to avoid overlap
              # highlight current annotation
              highlight = ifelse(is.numeric(myPars$currentAnn) &&
                                   i == myPars$currentAnn,
                                 TRUE, FALSE)
              segments(x0 = myPars$ann$from[i],
                       x1 = myPars$ann$to[i],
                       y0 = .5 + r, y1 = .5 + r,
                       lwd = ifelse(highlight, 3, 2),
                       col = ifelse(highlight, 'blue', 'black'))
              segments(x0 = myPars$ann$from[i],
                       x1 = myPars$ann$from[i],
                       y0 = .45 + r, y1 = .55 + r,
                       lwd = ifelse(highlight, 3, 2),
                       col = ifelse(highlight, 'blue', 'black'))
              segments(x0 = myPars$ann$to[i],
                       x1 = myPars$ann$to[i],
                       y0 = .45 + r, y1 = .55 + r,
                       lwd = ifelse(highlight, 3, 2),
                       col = ifelse(highlight, 'blue', 'black'))
              middle_i = mean(as.numeric(myPars$ann[i, c('from', 'to')]))
              text(x = middle_i,
                   y = .5 + r,
                   labels = myPars$ann$label[i],
                   adj = c(.5, 0), cex = 1.5)
            }
            par(mar = c(0.2, 2, 0.5, 2))
          }
        } else if (!is.null(myPars$spec)) {
          par(mar = c(0, 2, 0, 2))
          plot(1:10,
               type = 'n',
               bty = 'n',
               axes = FALSE,
               xlab = '', ylab = '')
          text(5, 5,
               labels = paste('Select a region of spectrogram and double-click',
                              'to create an annotation'))
        }
      })
    })
  }
  observeEvent(c(myPars$spec_xlim, myPars$currentAnn), drawAnn())

  observeEvent(myPars$currentAnn, {
    if (!is.null(myPars$currentAnn)) {
      if (myPars$print) print('Updating selection...')
      sel_points = as.numeric(round(myPars$ann[myPars$currentAnn, c('from', 'to')] /
                                      1000 * myPars$samplingRate))
      # in case of weird times in annotations, keep selection between 0 and audio length
      sel_points[1] = max(0, sel_points[1])
      sel_points[2] = min(sel_points[2], myPars$ls)
      idx_points = sel_points[1]:sel_points[2]
      myPars$selection = myPars$myAudio[idx_points]
      # move the spec view to show the selected ann
      ann_dur = myPars$ann$to[myPars$currentAnn] -
        myPars$ann$from[myPars$currentAnn]
      mid_view = mean(myPars$spec_xlim)
      mid_ann = mean(as.numeric(myPars$ann[myPars$currentAnn, c('from', 'to')]))
      shift = mid_ann - mid_view
      if (myPars$ann$from[myPars$currentAnn] < myPars$spec_xlim[1] |
          myPars$ann$to[myPars$currentAnn] > myPars$spec_xlim[2]) {
        if (diff(myPars$spec_xlim) > ann_dur) {
          # the ann fits based on current zoom level
          myPars$spec_xlim[1] = max(0, myPars$spec_xlim[1] + shift)
          myPars$spec_xlim[2] = min(myPars$dur, myPars$spec_xlim[2] + shift)
        } else {
          # zoom out enough to show the whole ann
          half_span = ann_dur * 1.5 / 2
          myPars$spec_xlim[1] = max(0, mid_ann - half_span)
          myPars$spec_xlim[2] = min(myPars$dur, mid_ann + half_span)
        }
      }
      hr()
    }
  })

  observeEvent(input$ann_click, {
    # select the annotation whose middle (label) is closest to the click
    if (!is.null(myPars$ann)) {
      ds = abs(input$ann_click$x - (myPars$ann$from + myPars$ann$to) / 2)
      myPars$currentAnn = which.min(ds)
      myPars$spectrogram_brush = list(xmin = myPars$ann$from[myPars$currentAnn],
                                      xmax = myPars$ann$to[myPars$currentAnn])
      myPars$cursor = myPars$ann$from[myPars$currentAnn]
      avFmPerSel()
    }
  })

  observeEvent(input$ann_dblclick, {
    # select and edit the double-clicked annotation
    if (!is.null(myPars$ann)) {
      ds = abs(input$ann_dblclick$x - (myPars$ann$from + myPars$ann$to) / 2)
      myPars$currentAnn = which.min(ds)
      showModal(dataModal_edit())
    }
  })

  dataModal_new = function() {
    myPars$listen_alphanum = FALSE
    myPars$listen_enter = TRUE
    modalDialog(
      textInput("annotation", "New annotation:",
                placeholder = '...some info...'
      ),
      footer = tagList(
        modalButton("Cancel"),
        actionButton("ok_new", "OK")
      ),
      easyClose = TRUE
    )
  }

  new_annotation = function() {
    if (myPars$print) print('Creating a new annotation...')
    new = data.frame(
      # idx = ifelse(is.null(myPars$ann), 1, nrow(myPars$ann) + 1),
      file = myPars$myAudio_filename,
      from = round(myPars$spectrogram_brush$xmin),
      to = round(myPars$spectrogram_brush$xmax),
      label = input$annotation,
      dF = NA, vtl = NA,
      stringsAsFactors = FALSE)
    new[, myPars$ff] = NA

    # depending on the history of changing input$nFormants / output.csv,
    # there may be more formants in myPars$ann than in the current sel
    if (is.null(myPars$ann)) {
      myPars$ann = new
    } else {
      myPars$ann = soundgen:::rbind_fill(myPars$ann, new)
    }

    # reorder and select the newly added annotation
    ord = order(myPars$ann$from)
    myPars$ann = myPars$ann[ord, ]
    myPars$currentAnn = which(ord == nrow(myPars$ann))

    # paste in formant frequencies
    avFmPerSel()
    myPars$ann[myPars$currentAnn, myPars$ff] = myPars$formants[myPars$ff]
    updateFBtn(myPars$formants[myPars$ff])
    updateVTL()

    # clear the selection, close the modal
    removeModal()
    myPars$listen_alphanum = TRUE
    myPars$listen_enter = FALSE
    drawAnn()
    avFmPerSel()
    # hr()

    # save a backup in case the app crashes before done() fires
    temp = soundgen:::rbind_fill(myPars$out, myPars$ann)
    temp = temp[order(temp$file), ]
    my_formants <<- temp
    write.csv(temp, 'www/temp.csv', row.names = FALSE)
  }

  observeEvent(input$ok_new, {
    new_annotation()
  })

  updateFBtn = function(ff) {
    if (myPars$print) print('Updating formant buttons...')
    for (f in 1:input$nFormants) {
      updateTextInput(session, inputId = paste0('F', f, '_text'),
                      value = ff[f])
    }
  }

  dataModal_edit = function() {
    myPars$listen_alphanum = FALSE
    myPars$listen_enter_edit = TRUE
    modalDialog(
      textInput("annotation", "New annotation:",
                placeholder = '...some info...'
      ),
      footer = tagList(
        modalButton("Cancel"),
        actionButton("ok_edit", "OK")
      ),
      easyClose = TRUE
    )
  }

  edit_annotation = function() {
    myPars$ann$label[myPars$currentAnn] = input$annotation
    removeModal()
    myPars$listen_alphanum = TRUE
    myPars$listen_enter_edit = FALSE
    drawAnn()
    # drawAnnTbl()
  }
  observeEvent(input$ok_edit, {
    edit_annotation()
  })



  ### ANALYZE
  extractFormants = reactive({
    if (!is.null(myPars$myAudio) & !is.null(myPars$regionToAnalyze)) {
      if (myPars$print) print('Extracting formants...')
      if (input$coeffs != '') {
        coeffs = as.numeric(input$coeffs)
      } else {
        coeffs = NULL
      }
      sel_anal = max(1, round(myPars$regionToAnalyze[1] / 1000 * myPars$samplingRate)) :
        min(myPars$ls, round(myPars$regionToAnalyze[2] / 1000 * myPars$samplingRate))
      temp_anal = try(soundgen:::.analyze(
        myPars$myAudio_list,
        windowLength = input$windowLength_lpc,
        step = input$step_lpc,
        wn = input$wn_lpc,
        zp = input$zp_lpc,
        dynamicRange = input$dynamicRange_lpc,
        silence = input$silence,
        formants = list(
          coeffs = coeffs,
          minformant = input$minformant,
          maxbw = input$maxbw
        ),
        nFormants = NULL,  # save all available formants
        pitchMethods = NULL,  # disable pitch tracking
        roughness = NULL,  # no roughness analysis
        novelty = NULL,    # no ssm
        loudness = NULL,   # no loudness analysis
        summaryFun = NULL,
        plot = FALSE,
        returnPitchCands = FALSE
      ))
      if (!inherits(temp_anal, 'try-error') &&
          length(temp_anal) > 0 &&
          is.list(temp_anal)) {
        myPars$temp_anal = temp_anal
        myPars$nMeasuredFmts = length(grep('_freq', colnames(myPars$temp_anal)))
        myPars$allF_colnames = paste0('f', 1:myPars$nMeasuredFmts, '_freq')
        myPars$ff = myPars$ff[1:min(input$nFormants, myPars$nMeasuredFmts)]
        myPars$temp_anal = myPars$temp_anal[, c('time', myPars$allF_colnames)]
        colnames(myPars$temp_anal) = c('time', paste0('F', 1:myPars$nMeasuredFmts))
        for (c in colnames(myPars$temp_anal)) {
          if (any(!is.na(myPars$temp_anal[, c]))) {
            # in case of all NAs
            myPars$temp_anal[, c] = round(myPars$temp_anal[, c])
          }
        }
        isolate({
          myPars$analyzedUpTo = myPars$regionToAnalyze[2]
          if (is.null(myPars$formantTracks)) {
            myPars$formantTracks = myPars$temp_anal
          } else {
            new_time_range = range(myPars$temp_anal$time)
            idx = which(myPars$formantTracks$time >= new_time_range[1] &
                          myPars$formantTracks$time <= new_time_range[2])
            if (length(idx) > 0) {
              myPars$formantTracks = myPars$formantTracks[-idx, ]
            }
            myPars$formantTracks = soundgen:::rbind_fill(myPars$formantTracks, myPars$temp_anal)
            # myPars$formantTracks = myPars$formantTracks[order(myPars$formantTracks$time), ]
          }
        })
      }
    }
  })

  # if any of LPC settings change, we re-analyze the entire file
  observeEvent(
    c(input$nFormants, input$silence, input$coeffs, input$minformant,
      input$maxbw, input$windowLength_lpc, input$step_lpc,
      input$wn_lpc, input$zp_lpc, input$dynamicRange_lpc), {
        myPars$analyzedUpTo = 0
      },
    ignoreInit = TRUE
  )

  avFmPerSel = function() {
    # analyze annotated selection
    if (!is.null(myPars$currentAnn) & !is.null(myPars$formantTracks)) {
      if (myPars$print) print('Averaging formants in selection...')
      isolate({
        # don't want dependency on myPars$ann
        idx = which(
          myPars$formantTracks$time >= myPars$ann$from[myPars$currentAnn] &
            myPars$formantTracks$time <= myPars$ann$to[myPars$currentAnn]
        )
      })
      fMat = myPars$formantTracks[idx, 2:(length(myPars$ff) + 1)]
      myPars$formants = apply(fMat, 2, function(x)
        round(do.call(input$summaryFun, list(x, na.rm = TRUE))))
      # myPars$bandwidth ?

      if (FALSE) {
        # fill in NAs
        idx_f_na = which(is.na(myPars$ann[myPars$currentAnn, myPars$ff]))
        if (length(idx_f_na) > 0)
          myPars$ann[myPars$currentAnn, myPars$ff[idx_f_na]] = myPars$formants[idx_f_na]
      }

      # fill in the formant boxes - note that we use the (possibly
      # user-modified) myPars$ann instead of myPars$formants
      updateFBtn(as.character(myPars$ann[myPars$currentAnn, myPars$ff]))
    }
  }
  observeEvent(myPars$formantTracks, avFmPerSel())

  observeEvent(input$defaultFmtBtn, {
    # browser()
    for (f in 1:input$nFormants) {
      fn = paste0('F', f, '_text')
      updateTextInput(
        session, fn,
        value = as.character(myPars$formants[f]))
    }
    myPars$ann[myPars$currentAnn, myPars$ff] = myPars$formants[myPars$ff]
    updateVTL()
  })

  observeEvent(myPars$ann, {
    if (myPars$print) print('Drawing ann_table...')
    if (!is.null(myPars$ann)) {
      # ann_for_print = myPars$ann[, which(!colnames(myPars$ann) %in% c('X', 'file'))]
      show_cols = c('from', 'to', 'label', 'dF', 'vtl', paste0('F', 1:ncol(myPars$ann)))
      ann_for_print = myPars$ann[, show_cols[which(show_cols %in% colnames(myPars$ann))]]
    } else {
      ann_for_print = '...waiting for some annotations...'
    }
    output$ann_table = renderTable(
      format(ann_for_print),
      align = 'c', striped = FALSE,
      bordered = TRUE, hover = FALSE, width = '100%'
    )
    hr()
  }, ignoreNULL = FALSE)

  hr = function() {
    if (!is.null(myPars$currentAnn)) {
      # Sys.sleep(.5)
      session$sendCustomMessage('highlightRow', myPars$currentAnn)
    }
  }

  updateVTL = function(rows = myPars$currentAnn) {
    if (!is.null(rows) && length(rows) > 0 && !is.null(myPars$ann[rows, ])) {
      if (myPars$print) print('Updating VTL...')
      for (i in rows) {
        if (!is.null(myPars$ann[i, ]) &&
            any(!is.na(myPars$ann[i, myPars$ff]))) {
          fmts_ann = as.numeric(myPars$ann[i, myPars$ff])
          vtl_ann = estimateVTL(
            formants = fmts_ann,
            method = input$vtl_method,
            speedSound = input$speedSound,
            interceptZero = input$interceptZero,
            tube = input$tube,
            output = 'detailed'
          )
          try({
            vtl_ann$formantDispersion = round(vtl_ann$formantDispersion)
          }, silent = TRUE)
          try({
            vtl_ann$vocalTract = round(vtl_ann$vocalTract, 2)
          }, silent = TRUE)
          myPars$ann$dF[i] = vtl_ann$formantDispersion
          myPars$ann$vtl[i] = vtl_ann$vocalTract
        }
      }
    }
  }
  observeEvent(c(input$vtl_method, input$speedSound, input$interceptZero, input$tube), {
    if (!is.null(myPars$ann) && nrow(myPars$ann) > 0) {
      updateVTL(rows = 1:nrow(myPars$ann))
    }
  })

  observeEvent(input$tableRow, {
    if (!is.null(myPars$ann) && input$tableRow > 0) {
      myPars$currentAnn = input$tableRow
      myPars$spectrogram_brush = list(xmin = myPars$ann$from[myPars$currentAnn],
                                      xmax = myPars$ann$to[myPars$currentAnn])
      avFmPerSel()
    }
  }, ignoreInit = TRUE)


  ## Buttons for operations with selection
  startPlay = function() {
    if (!is.null(myPars$myAudio)) {
      if (!is.null(myPars$spectrogram_brush) &&
          (myPars$spectrogram_brush$xmax - myPars$spectrogram_brush$xmin > 100)) {
        # at least 100 ms selected
        myPars$play$from = myPars$spectrogram_brush$xmin / 1000
        myPars$play$to = myPars$spectrogram_brush$xmax / 1000
      } else {
        myPars$play$from = myPars$cursor / 1000 # myPars$spec_xlim[1] / 1000
        myPars$play$to = myPars$spec_xlim[2] / 1000
      }
      myPars$play$dur = myPars$play$to - myPars$play$from
      myPars$play$timeOn = proc.time()
      myPars$play$timeOff = myPars$play$timeOn + myPars$play$dur / myPars$samplingRate_idx
      myPars$cursor_temp = myPars$cursor
      myPars$play$on = TRUE
      if (myPars$print) print('Playing selection...')

      # play selection
      if (input$audioMethod == 'Browser') {
        # play with javascript
        shinyjs::js$playme_js(  # need an external js script for this
          audio_id = 'myAudio',  # defined in tags$audio
          from = myPars$play$from / myPars$samplingRate_idx,
          to = myPars$play$to / myPars$samplingRate_idx)
      } else {
        # or play with R:
        playme(myPars$myAudio,
               samplingRate = myPars$samplingRate * myPars$samplingRate_idx,
               from = myPars$play$from / myPars$samplingRate_idx,
               to = myPars$play$to / myPars$samplingRate_idx)
      }
    }
  }
  observeEvent(c(input$selection_play), startPlay())  # add myPars$myAudio for autoplay

  stopPlay = function() {
    myPars$play$on = FALSE
    myPars$cursor = myPars$cursor_temp
    shinyjs::js$stopAudio_js(audio_id = 'myAudio')
  }
  observeEvent(input$selection_stop, stopPlay())

  observe({
    if (!is.null(myPars$play$on) && myPars$play$on) {
      time = proc.time()
      if (!is.null(myPars$slider_ms)) invalidateLater(myPars$slider_ms)
      if ((time - myPars$play$timeOff)[3] > 0) {
        myPars$play$on = FALSE
        myPars$cursor = myPars$cursor_temp  # reset to original cursor
      } else {
        myPars$cursor = myPars$play$from * 1000 + as.numeric(time - myPars$play$timeOn)[3] * 1000 * myPars$samplingRate_idx
        # [3] for "elapsed", ie "real" time
      }
    }
  })

  deleteSel = function() {
    if (!is.null(myPars$currentAnn)) {
      myPars$ann = myPars$ann[-myPars$currentAnn, ]
      myPars$selection = NULL
      myPars$currentAnn = NULL
      drawAnn()
      # drawAnnTbl()
    }
  }
  observeEvent(input$selection_delete, deleteSel())

  observeEvent(input$selection_annotate, {
    if (!is.null(myPars$spectrogram_brush)) {
      showModal(dataModal_new())
    }
  })

  # HOTKEYS
  observeEvent(input$userPressedSmth, {
    button_key = substr(input$userPressedSmth, 1, nchar(input$userPressedSmth) - 8)
    # see https://keycode.info/
    if (button_key == ' ') {                  # SPACEBAR (play / stop)
      if (myPars$play$on) stopPlay() else startPlay()
    } else if (button_key %in% c('Delete', 'Backspace')) {    # DELETE (delete current annotation)
      deleteSel()
    } else if (button_key == 'ArrowLeft') {    # ARROW LEFT (scroll left)
      shiftFrame('left', step = myPars$scrollFactor)
    } else if (button_key == 'ArrowRight') {    # ARROW RIGHT (scroll right)
      shiftFrame('right', step = myPars$scrollFactor)
    } else if (button_key == 'ArrowUp') {       # ARROW UP (horizontal zoom-in)
      changeZoom(myPars$zoomFactor)
    } else if (myPars$listen_alphanum & button_key %in% c('s', 'S')) {    # S (horizontal zoom to selection)
      zoomToSel()
    } else if (button_key == 'ArrowDown') {   # ARROW DOWN (horizontal zoom-out)
      changeZoom(1 / myPars$zoomFactor)
    } else if (button_key == '+') {     # + (vertical zoom-in)
      changeZoom_freq(1 / myPars$zoomFactor_freq)
    } else if (button_key == '-') {    # - (vertical zoom-out)
      changeZoom_freq(myPars$zoomFactor_freq)
    } else if (myPars$listen_alphanum & button_key %in% c('a', 'A')) {  # A (new annotation)
      if (!is.null(myPars$spectrogram_brush))
        showModal(dataModal_new())
    } else if (button_key == 'PageDown') {               # PageDown (next file)
      nextFile()   # disable b/c it's natural to press ENTER to close a modal win
    } else if (button_key == 'PageUp') {                # PageUp (previous file)
      lastFile()
    } else if ((myPars$listen_enter | myPars$listen_enter_edit) & button_key == 'Enter') {
      if (myPars$listen_enter) {
        new_annotation()
      } else {
        edit_annotation()
      }
    } else if (button_key == 'p') {
      synthAndPlay()
    }
  })


  ## ZOOM
  changeZoom_freq = function(coef) {
    # midpoint = mean(input$spec_ylim)
    # halfRan = diff(input$spec_ylim) / 2 / coef
    # newLow = max(0, midpoint - halfRan)
    # newHigh = min(myPars$samplingRate / 2, midpoint + halfRan)
    newHigh = min(input$spec_ylim[2] * coef, myPars$samplingRate / 2 / 1000)
    updateSliderInput(session, 'spec_ylim', value = c(0, newHigh))
  }
  observeEvent(input$zoomIn_freq, changeZoom_freq(1 / myPars$zoomFactor_freq))
  observeEvent(input$zoomOut_freq, changeZoom_freq(myPars$zoomFactor_freq))
  observeEvent(input$spec_ylim, {
    updateSliderInput(session, 'spectrum_xlim', value = input$spec_ylim)
  })

  changeZoom = function(coef, toCursor = FALSE) {
    # intelligent zoom-in a la Audacity: midpoint moves closer to selection/cursor
    if (!is.null(myPars$cursor) & toCursor) {
      if (!is.null(myPars$spectrogram_brush)) {
        midpoint = 3/4 * mean(c(myPars$spectrogram_brush$xmin,
                                myPars$spectrogram_brush$xmax)) +
          1/4 * mean(myPars$spec_xlim)
      } else {
        if (myPars$cursor > 0) {
          midpoint = 3/4 * myPars$cursor + 1/4 * mean(myPars$spec_xlim)
        } else {
          # when first opening a file, zoom in to the beginning
          midpoint = mean(myPars$spec_xlim) / coef
        }
      }
    } else {
      midpoint = mean(myPars$spec_xlim)
    }
    halfRan = diff(myPars$spec_xlim) / 2 / coef
    newLeft = max(0, midpoint - halfRan)
    newRight = min(myPars$dur, midpoint + halfRan)
    myPars$spec_xlim = c(newLeft, newRight)
    # use user-set time zoom in the next audio
    if (!is.null(myPars$spec_xlim) &&
        !any(!is.finite(myPars$spec_xlim)))
      myPars$initDur = diff(myPars$spec_xlim)
  }
  observeEvent(input$zoomIn, changeZoom(myPars$zoomFactor, toCursor = TRUE))
  observeEvent(input$zoomOut, changeZoom(1 / myPars$zoomFactor))
  zoomToSel = function() {
    if (!is.null(myPars$spectrogram_brush)) {
      myPars$spec_xlim = round(c(myPars$spectrogram_brush$xmin,
                                 myPars$spectrogram_brush$xmax))
    }
  }
  observeEvent(input$zoomToSel, {
    zoomToSel()
  })

  shiftFrame = function(direction, step = 1) {
    ran = diff(myPars$spec_xlim)
    shift = ran * step
    if (direction == 'left') {
      newLeft = max(0, myPars$spec_xlim[1] - shift)
      newRight = newLeft + ran
    } else if (direction == 'right') {
      newRight = min(myPars$dur, myPars$spec_xlim[2] + shift)
      newLeft = newRight - ran
    }
    myPars$spec_xlim = c(newLeft, newRight)
    # update cursor when shifting frame, but not when zooming
    myPars$cursor = myPars$spec_xlim[1]
  }
  observeEvent(input$scrollLeft, shiftFrame('left', step = myPars$scrollFactor))
  observeEvent(input$scrollRight, shiftFrame('right', step = myPars$scrollFactor))

  moveSlider = observe({
    if (myPars$print) print('Moving slider')
    width = round(diff(myPars$spec_xlim) / myPars$dur * 100, 2)
    left = round(myPars$spec_xlim[1] / myPars$dur * 100, 2)
    shinyjs::js$scrollBar(  # need an external js script for this
      id = 'scrollBar',  # defined in UI
      width = paste0(width, '%'),
      left = paste0(left, '%')
    )
    myPars$cursor = myPars$spec_xlim[1]
  })

  observeEvent(input$scrollBarLeft, {
    if (!is.null(myPars$spec)) {
      spec_span = diff(myPars$spec_xlim)
      scrollBarLeft_ms = input$scrollBarLeft * myPars$dur
      myPars$spec_xlim = c(max(0, scrollBarLeft_ms),
                           min(myPars$dur, scrollBarLeft_ms + spec_span))
    }
  }, ignoreInit = TRUE)

  observeEvent(input$scrollBarMove, {
    direction = substr(input$scrollBarMove, 1, 1)
    if (direction == 'l') {
      shiftFrame('left', step = myPars$scrollFactor)
    } else if (direction == 'r') {
      shiftFrame('right', step = myPars$scrollFactor)
    }
  }, ignoreNULL = TRUE)

  observeEvent(input$scrollBarWheel, {
    direction = substr(input$scrollBarWheel, 1, 1)
    if (direction == 'l') {
      shiftFrame('left', step = myPars$wheelScrollFactor)
    } else if (direction == 'r') {
      shiftFrame('right', step = myPars$wheelScrollFactor)
    }
  }, ignoreNULL = TRUE)

  observeEvent(input$zoomWheel, {
    direction = substr(input$zoomWheel, 1, 1)
    if (direction == 'l') {
      changeZoom(1 / myPars$zoomFactor)
    } else if (direction == 'r') {
      changeZoom(myPars$zoomFactor, toCursor = TRUE)
    }
  }, ignoreNULL = TRUE)

  # # step-overlap
  # observeEvent(input$overlap, {
  #   # change step if overlap changes, but don't change step if windowLength changes
  #   step = round(input$windowLength * (1 - input$overlap / 100))
  #   if (input$step != step)
  #     updateNumericInput(session, 'step', value = step)
  # }, ignoreInit = TRUE)
  # observeEvent(c(input$step, input$windowLength), {
  #   # change overlap if step or windowLength change
  #   overlap = (1 - input$step / input$windowLength) * 100
  #   if (input$overlap != overlap)
  #     updateSliderInput(session, 'overlap', value = overlap)
  # })
  #
  #   observeEvent(input$overlap_lpc, {
  #     # change step if overlap changes, but don't change step if windowLength changes
  #     step_lpc = round(input$windowLength_lpc * (1 - input$overlap_lpc / 100))
  #     if (input$step_lpc != step_lpc)
  #       updateNumericInput(session, 'step_lpc', value = step_lpc)
  #   }, ignoreInit = TRUE)
  #   observeEvent(c(input$step_lpc, input$windowLength_lpc), {
  #     # change overlap if step or windowLength change
  #     overlap_lpc = (1 - input$step_lpc / input$windowLength_lpc) * 100
  #     if (input$overlap_lpc != overlap_lpc)
  #       updateSliderInput(session, 'overlap_lpc', value = overlap_lpc)
  #   })


  # SAVE OUTPUT
  done = function() {
    # meaning we are done with a sound - prepares the output
    updateVTL()  # in case of glitches (smts VTL is not updated
    # as it should be when ff are changed manually)
    if (myPars$print) print('Running done()...')
    if (!is.null(myPars$ann)) {
      if (is.null(myPars$out)) {
        myPars$out = myPars$ann
      } else {
        # remove previous records for this file, if any
        idx = which(myPars$out$file == myPars$myAudio_filename)
        if (length(idx) > 0)
          myPars$out = myPars$out[-idx, ]

        # append annotations from the current audio
        myPars$out = soundgen:::rbind_fill(myPars$out, myPars$ann)
      }
      # keep track of formant tracks and spectrograms
      # to avoid analyzing them again if the user goes
      # back and forth between files
      myPars$out_fTracks[[myPars$myAudio_filename]] = myPars$formantTracks
      myPars$out_spects[[myPars$myAudio_filename]] = myPars$spec
    }
    if (!is.null(myPars$out)) {
      # re-order and save a backup
      myPars$out = myPars$out[order(myPars$out$file, myPars$out$from), ]
      my_formants <<- myPars$out
      write.csv(myPars$out, 'www/temp.csv', row.names = FALSE)
    }
  }

  observeEvent(input$fileList, {
    done()
    myPars$n = which(myPars$fileList$name == input$fileList)
    reset()
    if (length(myPars$n) == 1 && myPars$n > 0) readAudio(myPars$n)
  }, ignoreInit = TRUE)

  nextFile = function() {
    if (!is.null(myPars$myAudio_path)) {
      done()
      if (myPars$n < myPars$nFiles) {
        myPars$n = myPars$n + 1
        updateSelectInput(session, 'fileList',
                          selected = myPars$fileList$name[myPars$n])
        # ...which triggers observeEvent(input$fileList)
      }
    }

  }
  observeEvent(input$nextFile, nextFile())

  lastFile = function() {
    if (!is.null(myPars$myAudio_path)) {
      done()
      if (myPars$n > 1) {
        myPars$n = myPars$n - 1
        updateSelectInput(session, 'fileList',
                          selected = myPars$fileList$name[myPars$n])
      }
    }
  }
  observeEvent(input$lastFile, lastFile())

  output$saveRes = downloadHandler(
    filename = function() 'output.csv',
    content = function(filename) {
      done()  # finalize the last file
      write.csv(myPars$out, filename, row.names = FALSE)
      my_formants <<- myPars$out
      if (file.exists('www/temp.csv')) file.remove('www/temp.csv')
    }
  )

  observeEvent(input$about, {
    if (myPars$debugQn) {
      browser()  # back door for debugging)
    } else {
      showNotification(
        ui = paste0(
          "App for measuring formants in small annotated segments: soundgen ",
          packageVersion('soundgen'), ". Spectrogram: select an area and ",
          "double-click to add an annotation, left-click to correct a formant ",
          "measure. Spectrum: single-click to update a formant to cursor ",
          "position, double click to update to the closest spectral peak. More ",
          "info: ?formant_app and http://cogsci.se/soundgen.html"),
        duration = 20,
        closeButton = TRUE,
        type = 'default'
      )
    }
  })

  synthAndPlay = function() {
    if (!is.null(myPars$ann[myPars$currentAnn, ]) &&
        any(!is.na(myPars$ann[myPars$currentAnn, myPars$ff]))) {
      if (myPars$print) print('Calling soundgen()...')
      pitch = list(time = c(0, .1, .9, 1),
                   value = c(input$pitch / 1.5, input$pitch,
                             input$pitch / 1.11, input$pitch / 1.5))
      if (input$adaptivePitch)
        pitch$value = pitch$value * 17 / myPars$ann$vtl[myPars$currentAnn]
      ff_vector = as.numeric(myPars$ann[myPars$currentAnn, myPars$ff])
      # drop NAs in formant frequencies, otherwise soundgen can't synthesize them
      ff_vector = ff_vector[!is.na(ff_vector)]
      temp_s = try(soundgen(
        sylLen = 300 * myPars$samplingRate_idx,
        pitch = pitch,
        formants = ff_vector,
        temperature = .001, tempEffects = list(formDisp = 0, formDrift = 0)))
      if (!inherits(temp_s, 'try-error')) {
        if (input$audioMethod == 'Browser') {
          # save a temporary file and play with the browser
          seewave::savewav(temp_s,
                           f = 16000 * myPars$samplingRate_idx,
                           filename = 'www/temp.wav')
          shinyjs::js$play_file(filename = 'temp.wav')
        } else {
          # play directly in R without saving to disk
          playme(temp_s, samplingRate = 16000 * myPars$samplingRate_idx)
        }
      }
    }
  }
  observeEvent(input$synthBtn, synthAndPlay())

  ### TOOLTIPS - have to be here instead of UI b/c otherwise problems with regulating delay
  # (see https://stackoverflow.com/questions/47477237/delaying-and-expiring-a-shinybsbstooltip)
  ## Analysis
  # LPC
  shinyBS::addTooltip(session, id='reset_to_def', title = 'Reset all settings to default values', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='audioMethod', title = "Play audio with javascript (recommended in Firefox, doesn't work in Chrome) or with R (browser-independent, but then the cursor doesn't move, and you can't stop playback)", placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='samplingRate_mult', title = 'Speed up or slow down the original and synthesized audio for playback purposes only, without affecting the measurements (eg to make it sound more human-like)', placement="below", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))

  # LPC
  shinyBS::addTooltip(session, id='nFormants', title = 'Number of formants to analyze', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='silence', title = 'Frames below this threshold are not analyzed', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='coeffs', title = 'The number of LPC coefficients (see ?phonTools::findformants)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='minformant', title = 'Lowest accepted formant frequency (see ?phonTools::findformants)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='maxbw', title = 'Maximum accepted formant bandwidth (see ?phonTools::findformants)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))

  # Windowing
  shinyBS::addTooltip(session, id='windowLength_lpc', title = 'Length of STFT window for LPC analysis, ms. Independent of the window used for plotting the spectrogram', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='step_lpc', title = 'Step between analysis frames, ms', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  # shinyBS::addTooltip(session, id='overlap_lpc', title = 'Overlap between analysis frames, %', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='dynamicRange_lpc', title = 'Dynamic range, dB', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='zp_lpc', title = 'Zero padding: 8 means 2^8 = 256, etc.', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='wn_lpc', title = 'Type of STFT window', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))

  # Vocal tract
  shinyBS::addTooltip(session, id='vtl_method', title = 'Method of calculating vocal tract length (VTL). See ?estimateVTL', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='speedSound', title = 'VTL estimate depends on the assumed speed of sound inside the vocal tract', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='interceptZero', title = 'Set the regression intercept to zero to reduce the influence of lower formants (careful with whether you are using closed-open or open-open tube approximation) ', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='tube', title = 'The vocal tract can be modeled as a uniform tube either closed at one end and open at the other, or else as having both ends either closed or open', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))

  ## Plotting
  # spectrogram
  shinyBS::addTooltip(session, id='spec_ylim', title = "Range of displayed frequencies, kHz", placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='windowLength', title = 'Length of STFT window for plotting the spectrogram', placement="rights", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='step', title = 'Step between analysis frames, ms', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='specType', title = 'Spectrogram type, argument "specType" in spectrogram(). Visualization only, does NOT affect formant tracking with LPC', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  # shinyBS::addTooltip(session, id='overlap', title = 'Overlap between analysis frames, %', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='dynamicRange', title = 'Dynamic range, dB', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='spec_cex', title = "Magnification coefficient controlling the size of points showing formant candidates", placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='specContrast', title = 'Regulates the contrast of the spectrogram', placement="below", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='specBrightness', title = 'Regulates the brightness of the spectrogram', placement="below", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='blur_freq', title = 'Gaussian filter of frequency: >0 = blur, <0 = unblur (sharpen)', placement="below", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='blur_time', title = 'Gaussian filter of time: >0 = blur, <0 = unblur (sharpen)', placement="below", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='zp', title = 'Zero padding: 8 means 2^8 = 256, etc.', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='wn', title = 'Type of STFT window', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='spec_maxPoints', title = 'The number of points to plot in the spectrogram (smaller = faster, but low resolution)', placement="below", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))

  # oscillogram
  shinyBS::addTooltip(session, id='osc', title = 'The type of oscillogram to show', placement="below", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='osc_maxPoints', title = 'The number of points to plot in the oscillogram (smaller = faster, but low resolution)', placement="below", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))

  # spectrum
  shinyBS::addTooltip(session, id='spectrum_xlim', title = 'Range of displayed frequencies, kHz (normally tied to spectrogram range)', placement="below", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='spectrum_len', title = 'The number of points to plot in the spectrum (smaller = faster, but low resolution)', placement="below", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))

  # action buttons
  shinyBS:::addTooltip(session, id='lastFile', title='Save and return to the previous file (PageUp)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS:::addTooltip(session, id='nextFile', title='Save and proceed to the next file (PageDown)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS:::addTooltip(session, id='selection_stop', title='Stop playback', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS:::addTooltip(session, id='selection_play', title='Play selection (SPACEBAR)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS:::addTooltip(session, id='selection_annotate', title='Create a new annotation (A or DOUBLE-CLICK)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS:::addTooltip(session, id='selection_delete', title='Remove annotation (DELETE / BACKSPACE)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='saveRes', title = 'Download results (see ?pitch_app for recovering unsaved data after a crash)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='synthBtn', title = 'Synthesize and play a vowel with formants as measured in the current annotation. Check ?playme() if no sound', placement="bottom", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))

  # navigation / zoom
  shinyBS::addTooltip(session, id='zoomIn_freq', title = 'Zoom in frequency (+)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='zoomOut_freq', title = 'Zoom out frequency (-)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='scrollLeft', title = 'Scroll left (arrow LEFT)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='zoomOut', title = 'Zoom out time (arrow DOWN)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='zoomToSel', title = 'Zoom to selection (S)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='zoomIn', title = 'Zoom in time (arrow UP)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
  shinyBS::addTooltip(session, id='scrollRight', title = 'Scroll right (arrow RIGHT)', placement="right", trigger="hover", options = list(delay = list(show = 1000, hide = 0)))
}
tatters/soundgen documentation built on Aug. 22, 2023, 4:24 p.m.