voice-graph.js 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. class VoiceGraph {
  2. constructor(element) {
  3. this.element = element;
  4. element.style.position = 'relative';
  5. element.style.boxSizing = 'border-box'
  6. let size = element.clientWidth;
  7. this.pitchUpperBoundHz = 300;
  8. this.pitchLowerBoundHz = 50;
  9. this.pitchRange = this.pitchUpperBoundHz - this.pitchLowerBoundHz;
  10. for (let child of [
  11. div({'class': 'x axis-labels'},
  12. ['0%', '← Resonance →', '100%'].map((s, i) => span(
  13. s, {style: 'min-width: 3em; text-align: center;', 'class': i == 1 ? 'title' : ''}
  14. ))
  15. ),
  16. div({'class': 'x-opposite axis-labels'}, [
  17. this.xValueLabel = span('25%', {'class': 'current-value'})
  18. ]),
  19. div({'class': 'y axis-labels'},
  20. [this.pitchUpperBoundHz + 'Hz', '← Pitch →',
  21. this.pitchLowerBoundHz + 'Hz'
  22. ].map((s, i) => span(
  23. s, {style: 'min-height: 3em; text-align: center;', 'class': i == 1 ? 'title' : ''})
  24. )
  25. ),
  26. div({'class': 'y-opposite axis-labels'}, [
  27. this.yValueLabel = span('25%', {'class': 'current-value'})
  28. ]),
  29. div({'class': 'overlay'}, [
  30. this.xHairline = span(' ', {'class': 'x hairline hidden'}),
  31. this.yHairline = span(' ', {'class': 'y hairline hidden'}),
  32. ], ' '),
  33. div('𝄞', {'class': 'treble clef'}),
  34. div('𝄢', {'class': 'bass clef'}),
  35. div({'class': 'instrument flute'}, String(brightIcon)),
  36. div({ 'class': 'instrument tuba'}, String(darkIcon)),
  37. this.canvas = create('canvas', {
  38. height: size,
  39. width: size,
  40. })
  41. ]) {
  42. element.appendChild(child);
  43. }
  44. // displays a plane which blends colors
  45. // from cool to warm, leftward, on the x-axis
  46. // and from light to dark, downard, on the y-axis.
  47. let ctx = this.canvas.getContext('2d');
  48. let rgba = ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
  49. let pink = {r: 255, g: 40, b: 0};
  50. let blue = {r: 0, g: 100, b: 255};
  51. for (let i = 0; i < rgba.data.length; i += 4) {
  52. let rise = ((i/4) / rgba.height) / rgba.height;
  53. let run = ((i/4) % rgba.width) / rgba.width;
  54. let pinkBlue = this.lighten(
  55. this.blend(blue, pink, (2/3)*run + (1/3)*(1 - rise)),
  56. 1 - (1.5 * rise)**(.5)
  57. )
  58. rgba.data[i] = pinkBlue.r;
  59. rgba.data[i+1] = pinkBlue.g;
  60. rgba.data[i+2] = pinkBlue.b;
  61. rgba.data[i+3] = 255;
  62. }
  63. ctx.putImageData(rgba, 0, 0);
  64. window.addEventListener('resize', evt => {
  65. for (let marker of $$('.marker')) {
  66. this.update(marker);
  67. }
  68. });
  69. globalState.render(['clips'], current => {
  70. let orphanedMarkers = Array.from($$('.marker'));
  71. for (let clip of current.clips) {
  72. if (!clip.marker) {
  73. clip.marker = this.addMarker(pitchPercent(clip.medianPitch) || .5, clip.medianResonance || .5, null, null);
  74. clip.marker.setAttribute('data-id', clip.id);
  75. clip.marker.style.background = clip.color;
  76. clip.marker.addEventListener('click', evt => {
  77. if (globalState.get('playingClip') == clip) {
  78. globalState.set('playing', !globalState.get('playing'));
  79. } else {
  80. $('.tab-set button.details').click();
  81. globalState.set('playbackTime', 0);
  82. globalState.set('playing', false);
  83. globalState.set('playingClip', clip);
  84. globalState.set('previewClip', clip);
  85. }
  86. });
  87. clip.marker.addEventListener('mouseenter', evt => {
  88. globalState.set('previewClip', clip);
  89. for (let marker of $$('.marker')) this.update(marker);
  90. });
  91. this.update(clip.marker);
  92. } else {
  93. let index = orphanedMarkers.indexOf(clip.marker);
  94. orphanedMarkers.splice(index, 1);
  95. }
  96. }
  97. for (let marker of orphanedMarkers) {
  98. marker.parentNode.removeChild(marker);
  99. }
  100. })
  101. globalState.render(['previewClip'], current => {
  102. for (let marker of $$('.marker')) {
  103. marker.classList.remove('preview');
  104. this.update(marker);
  105. }
  106. if (current.previewClip && current.previewClip.marker) {
  107. current.previewClip.marker.classList.add('preview');
  108. }
  109. let hairlines = $$('voice-graph-2d > .overlay > .hairline');
  110. for (let hairline of hairlines) {
  111. if (current.previewClip) {
  112. hairline.classList.remove('hidden');
  113. } else {
  114. hairline.classList.add('hidden');
  115. }
  116. }
  117. })
  118. globalState.render(['playingClip'], current => {
  119. for (let marker of $$('.marker')) {
  120. marker.querySelector('.infobox').innerHTML = '';
  121. marker.classList.remove('playing');
  122. };
  123. if (current.playingClip && current.playingClip.marker) {
  124. current.playingClip.marker.classList.add('playing');
  125. }
  126. })
  127. globalState.render(['playbackTime'], current => {
  128. let timeIndex = Math.floor(current.playbackTime * 100);
  129. let playingClip = globalState.get('playingClip');
  130. if (!playingClip ||
  131. !playingClip.indexedPhones ||
  132. timeIndex >= playingClip.indexedPhones.length ||
  133. !playingClip.marker
  134. ) return;
  135. let currentPhone = playingClip.indexedPhones[timeIndex];
  136. playingClip.marker.querySelector('.infobox').innerHTML =
  137. currentPhone.word.word == '' ? ''
  138. : (currentPhone.word.word.replace(/^\w/, (c) => c.toUpperCase()) + ' - ')
  139. + currentPhone.phoneme;
  140. if (current.playbackTime == 0 || Math.abs(current.playbackTime - last(playingClip.phones).time) < 1 ) {
  141. playingClip.marker.setAttribute('data-pitch', pitchPercent(playingClip.medianPitch) || .5);
  142. playingClip.marker.setAttribute('data-resonance', playingClip.medianResonance || .5);
  143. } else {
  144. let isVowel = currentPhone.phoneme && Array.from(currentPhone.phoneme).filter(
  145. value => ["A", "E", "I", "O", "U", "Y"].includes(value)
  146. ).length > 0;
  147. if (isVowel && currentPhone.hasOwnProperty('F_stdevs') &&
  148. currentPhone.F_stdevs[0] && currentPhone.F_stdevs[1] &&
  149. currentPhone.F_stdevs[2] && currentPhone.F_stdevs[3]
  150. ) {
  151. if (currentPhone != null && currentPhone.F[0] &&isVowel) {
  152. playingClip.marker.setAttribute('data-pitch', pitchPercent(currentPhone.F[0]));
  153. }
  154. if (currentPhone.F_stdevs && isVowel) {
  155. playingClip.marker.setAttribute('data-resonance', clamp(0, 1,
  156. ((0.7321428571428571 * currentPhone.F_stdevs[1]
  157. + 0.26785714285714285 * currentPhone.F_stdevs[2]
  158. /*+ 0 * currentPhone.F_stdevs[3]*/) + 2) / 4
  159. ));
  160. }
  161. }
  162. }
  163. this.update(playingClip.marker);
  164. });
  165. }
  166. getClipById(id) {
  167. for (let marker of $$('.marker')) {
  168. if (marker.getAttribute('data-id') == id) {
  169. return marker;
  170. }
  171. }
  172. }
  173. addMarker(pitch, resonance, label, ratings) {
  174. let newMarker;
  175. this.element.querySelector('.overlay').appendChild(
  176. newMarker = button(label || ' ', {
  177. 'class': 'marker',
  178. 'data-pitch': pitch,
  179. 'data-resonance': resonance,
  180. }, [
  181. div({'class' : 'infobox'}, ratings == null ? [] : [
  182. div({'class':'ratings-bar'}, [
  183. 'strongly-disagree', 'disagree',
  184. 'agree', 'strongly-agree'
  185. ].map((e, i) => span(
  186. ratings[i] > .14 ? this.percent(ratings[i]) : ' ',
  187. {'class': e, style: `width: ${this.percent(ratings[i])}`}
  188. ))
  189. )
  190. ])
  191. ])
  192. )
  193. let vg = this;
  194. let showDetails = evt => {
  195. vg.update(newMarker);
  196. }
  197. newMarker.addEventListener('mouseover', showDetails);
  198. newMarker.addEventListener('click', showDetails);
  199. this.update(newMarker);
  200. return newMarker;
  201. }
  202. // Set visual positioning of markers and labels
  203. // to match the values in `data-pitch` and `data-resonance`.
  204. update(marker) {
  205. let overlay = $('.overlay');
  206. let pitch = parseFloat(marker.getAttribute('data-pitch'));
  207. let resonance = parseFloat(marker.getAttribute('data-resonance'));
  208. let translateX = `${Math.round(overlay.clientWidth * resonance)}px`;
  209. let translateY = `${Math.round(overlay.clientHeight * (1-pitch))}px`;
  210. let markerTranslateY = `${-Math.round(overlay.clientHeight * pitch)}px`;
  211. let hairTranslateY = `${Math.round(overlay.clientHeight * (1 - pitch))}px`;
  212. marker.style.transform = `translate(${translateX}, ${translateY})`;
  213. // Update the hairlines and labels
  214. this.xHairline.style.border = '1px solid red';
  215. let previewClip = globalState.get('previewClip');
  216. if (previewClip && previewClip.marker && marker == previewClip.marker) {
  217. this.xValueLabel.style.transform = `translate(${translateX}, 0px)`;
  218. this.yValueLabel.style.transform = `translate(0px, ${markerTranslateY})`;
  219. // Doesn't move unless there's a delay
  220. let hairx = this.xHairline;
  221. let hairy = this.yHairline;
  222. setTimeout(() => {
  223. $('.x.hairline').style.transform = `translate(${translateX}, 0px)`;
  224. $('.y.hairline').style.transform = `translate(0px, ${hairTranslateY})`;
  225. }, 1);
  226. this.xValueLabel.innerHTML = `${Math.round(resonance * 100)}%`;
  227. this.yValueLabel.innerHTML = `${Math.round(
  228. this.pitchLowerBoundHz + pitch * this.pitchRange
  229. )}Hz`;
  230. this.yHairline.style.opacity = '1';
  231. this.yValueLabel.style.opacity = '1';
  232. this.xHairline.style.opacity = '1';
  233. this.xValueLabel.style.opacity = '1';
  234. }
  235. }
  236. /*
  237. preview(recordings) {
  238. for (recording of recordings) {
  239. let el = document.createElement('div');
  240. let vg = this;
  241. el.addEventListener('click', evt => {
  242. vg.play(recording);
  243. vg.preview(recordings);
  244. })
  245. }
  246. }
  247. */
  248. /* Color manipulation */
  249. lighten(color, proportion) {
  250. return proportion >= 0
  251. ? { r: color.r + (255 - color.r) * proportion,
  252. g: color.g + (255 - color.g) * proportion,
  253. b: color.b + (255 - color.b) * proportion, }
  254. : { r: color.r - color.r * -proportion,
  255. g: color.g - color.g * -proportion,
  256. b: color.b - color.b * -proportion, }
  257. }
  258. blend(color1, color2, amount) {
  259. return {
  260. r: color1.r * (1 - amount) + color2.r * amount,
  261. g: color1.g * (1 - amount) + color2.g * amount,
  262. b: color1.b * (1 - amount) + color2.b * amount,
  263. }
  264. }
  265. percent(x) {
  266. return Math.floor(x * 100) + '%';
  267. }
  268. }
  269. for (let graph of document.querySelectorAll('voice-graph-2d')) {
  270. graph.voiceGraph = new VoiceGraph(graph);
  271. }