evaluating.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. import { Button, Toast } from 'vant'
  2. import { defineComponent, Directive, onBeforeUnmount, onMounted, Ref, ref, Teleport } from 'vue'
  3. import '@dotlottie/player-component'
  4. import ButtonIcon from './icon'
  5. import detailState from '/src/pages/detail/state'
  6. import SettingState from '/src/pages/detail/setting-state'
  7. import appState from '/src/state'
  8. import {
  9. Fraction
  10. } from '/osmd-extended/src'
  11. import {
  12. IPostMessage,
  13. listenerMessage,
  14. postMessage,
  15. promisefiyPostMessage,
  16. removeListenerMessage,
  17. } from '/src/helpers/native-message'
  18. import { browser, getRequestHostname } from '/src/helpers/utils'
  19. import runtime, * as RuntimeUtils from '/src/pages/detail/runtime'
  20. import { getBoundingBoxByverticalNote, getNoteByMeasuresSlursStart, getParentNote } from '/src/pages/detail/helpers'
  21. import { useClientType, useOriginSearch } from '../uses'
  22. import { startButtonShow } from './index'
  23. import { getLeveByScoreMeasure } from '/src/pages/detail/evaluating/helper'
  24. import Evaluating, { evaluatingShow } from '../popups/evaluating'
  25. // @ts-ignore
  26. import StartEvaluating from './dotlotties/start-evaluating.lottie?url'
  27. import iconStartEvaluating from './dotlotties/StartEvaluating.png'
  28. import iconRecord from './dotlotties/iconRecord.png'
  29. // @ts-ignore
  30. import Recording from './dotlotties/recording2.lottie?url'
  31. import request from '/src/helpers/request'
  32. import styles from './index.module.less'
  33. let backtime = 0
  34. const initBehaviorId = '' + new Date().valueOf()
  35. const evaluating = ref(false)
  36. const playStatus: Ref<'connecting' | 'play' | 'stop'> = ref('stop')
  37. const endloading = ref(false)
  38. const connentLoading = ref(false)
  39. const playUrl: Ref<string> = ref('')
  40. const endResult = ref(null)
  41. export const animate: Directive = {
  42. mounted: (el: HTMLElement) => {
  43. el.addEventListener('click', (evt: Event) => {
  44. let element = evt.target as HTMLElement
  45. element.classList.add(...['animate__animated', 'animate__tada'])
  46. })
  47. el.addEventListener('animationend', (evt: Event) => {
  48. let element = evt.target as HTMLElement
  49. element.classList.remove(...['animate__animated', 'animate__tada'])
  50. })
  51. },
  52. }
  53. const browserInfo = browser()
  54. /**
  55. * 默认按照442计算的音符频率,此处转化为按照设置进行调整
  56. * @param num 频率
  57. * @returns 转化后频率
  58. */
  59. const formatPitch = (num?: number): number => {
  60. if (!num) {
  61. return -1
  62. }
  63. if (SettingState.sett.hertz && SettingState.sett.hertz !== 442) {
  64. return (num / 442) * SettingState.sett.hertz
  65. }
  66. return num
  67. }
  68. const formatTimes = () => {
  69. const difftime = detailState.times?.[0]?.difftime || 0
  70. let ListenMode = false
  71. let dontEvaluatingMode = false
  72. let skip = false
  73. const datas = []
  74. for (let index = 0; index < detailState.times.length; index++) {
  75. const item = detailState.times[index]
  76. const note = getNoteByMeasuresSlursStart(item)
  77. // console.log(item.nodeElement)
  78. const rate = runtime.speed / detailState.baseSpeed //1
  79. // const fixtime = 0
  80. const start = difftime + (item.sourceRelativeTime || item.relativeTime)
  81. const end = difftime + (item.sourceRelaEndtime || item.relaEndtime)
  82. const isStaccato = typeof note.voiceEntry.isStaccato === 'function' ? note.voiceEntry.isStaccato() : note.voiceEntry.isStaccato
  83. const noteRate = isStaccato ? 0.5 : 1
  84. if (note.formatLyricsEntries.contains('Play') || note.formatLyricsEntries.contains('Play...')) {
  85. ListenMode = false
  86. }
  87. if (note.formatLyricsEntries.contains('Listen')) {
  88. ListenMode = true
  89. }
  90. if (note.formatLyricsEntries.contains('纯律结束')) {
  91. dontEvaluatingMode = false
  92. }
  93. if (note.formatLyricsEntries.contains('纯律')) {
  94. dontEvaluatingMode = true
  95. }
  96. const nextNote = detailState.times[index + 1]
  97. // console.log("noteinfo", note.noteElement.isRestFlag && !!note.stave && !!nextNote)
  98. if (skip && (note.stave || !note.noteElement.isRestFlag || (nextNote && !nextNote.noteElement.isRestFlag))) {
  99. skip = false
  100. }
  101. if (note.noteElement.isRestFlag && !!note.stave && !!nextNote && nextNote.noteElement.isRestFlag) {
  102. skip = true
  103. }
  104. // console.log(note.measureOpenIndex, item.measureOpenIndex, note)
  105. // console.log("skip", skip)
  106. const data = {
  107. timeStamp: (start * 1000) / rate,
  108. duration: ((end * 1000) / rate - (start * 1000) / rate) * noteRate,
  109. frequency: formatPitch(item.noteElement?.pitch?.frequency),
  110. nextFrequency: formatPitch(item.noteElement?.pitch?.nextFrequency),
  111. prevFrequency: formatPitch(item.noteElement?.pitch?.prevFrequency),
  112. // 重复的情况index会自然累加,render的index是谱面渲染的index
  113. measureIndex: note.measureOpenIndex,
  114. measureRenderIndex: note.noteElement.sourceMeasure.measureListIndex,
  115. // dontEvaluating: ListenMode,
  116. dontEvaluating: ListenMode || dontEvaluatingMode || item.skipMode,
  117. musicalNotesIndex: item.i,
  118. denominator: note.noteElement?.Length.denominator,
  119. isOrnament: !!note?.voiceEntry?.ornamentContainer
  120. }
  121. datas.push(data)
  122. }
  123. // console.log("🚀 ~ datas", datas)
  124. return datas
  125. }
  126. const connect = async () => {
  127. const search = useOriginSearch()
  128. connentLoading.value = true
  129. const behaviorId = sessionStorage.getItem('behaviorId') || search.behaviorId || initBehaviorId
  130. const rate = runtime.speed / detailState.baseSpeed //1
  131. const content = {
  132. musicXmlInfos: formatTimes(),
  133. // id: search.id,
  134. subjectId: detailState.subjectId,
  135. detailId: detailState.activeDetail?.id,
  136. examSongId: search.id,
  137. xmlUrl: detailState?.activeDetail?.xmlUrl,
  138. partIndex: detailState.partIndex,
  139. behaviorId,
  140. platform: 'WEB',
  141. clientId: 'STUDENT',
  142. hertz: SettingState.sett.hertz,
  143. feature: search.feature || 'PRACTICE',
  144. // 这里定义的是数字但是因为是通过input输入所以强制转化一次
  145. reactionTimeMs: parseFloat('' + SettingState.eva.reactionTimeMs) || 0,
  146. speed: runtime.speed,
  147. heardLevel: SettingState.eva.difficulty,
  148. beatLength: Math.round((RuntimeUtils.getFixTime(detailState.times[0].beatSpeed) * 1000) / rate),
  149. }
  150. const clientType = useClientType()
  151. if (clientType === 'student') {
  152. content.clientId = 'STUDENT'
  153. } else if (clientType === 'teacher') {
  154. content.clientId = 'TEACHER'
  155. } else {
  156. content.clientId = 'BACKEND'
  157. }
  158. if (browserInfo.android) {
  159. content.platform = 'ANDROID'
  160. }
  161. if (browserInfo.ios) {
  162. content.platform = 'IOS'
  163. }
  164. // console.log("评测数据", content)
  165. const evt = await promisefiyPostMessage({
  166. api: 'startEvaluating',
  167. content: content,
  168. })
  169. if (evt?.content?.reson) {
  170. Toast.fail({
  171. message: evt?.content?.reson,
  172. })
  173. connentLoading.value = false
  174. throw evt
  175. }
  176. connentLoading.value = false
  177. }
  178. const sendOffsetTime = (offsetTime: number) => {
  179. postMessage(
  180. {
  181. api: 'proxyServiceMessage',
  182. content: {
  183. header: {
  184. commond: 'audioPlayStart',
  185. type: 'SOUND_COMPARE',
  186. },
  187. body: {
  188. offsetTime,
  189. },
  190. },
  191. },
  192. () => {
  193. backtime = 0
  194. }
  195. )
  196. }
  197. const cancelTheEvaluation = () => {
  198. const search = useOriginSearch()
  199. RuntimeUtils.resetPlayStatus()
  200. RuntimeUtils.clearIntervalTimeline()
  201. RuntimeUtils.setCurrentTime(0)
  202. playStatus.value = 'stop'
  203. postMessage(
  204. {
  205. api: 'endEvaluating',
  206. content: {
  207. musicScoreId: search.id,
  208. },
  209. },
  210. (evt) => {
  211. evaluating.value = false
  212. // RuntimeUtils.setCaptureMode()
  213. Toast.clear()
  214. }
  215. )
  216. }
  217. const stopPlay = (show: boolean = true) => {
  218. console.log('调用stopPlay')
  219. if (show){
  220. // Toast({
  221. // duration: 0,
  222. // message: '评分中...',
  223. // type: 'loading',
  224. // })
  225. }
  226. startButtonShow.value = true
  227. cancelTheEvaluation()
  228. }
  229. export const evaluatStopPlay = stopPlay
  230. const startPlay = () => {
  231. console.log('连接服务成功,开始播放', new Date().getTime() - runtime.clickTime)
  232. // synced = false
  233. if (!SettingState.eva.mute) {
  234. RuntimeUtils.changeAllMode()
  235. } else {
  236. RuntimeUtils.changeMode('background')
  237. }
  238. startButtonShow.value = false
  239. // RuntimeUtils.changeSpeed(90)
  240. RuntimeUtils.setPlayState()
  241. }
  242. const setPlayer = async () => {
  243. console.log('调用setPlayer')
  244. // this.startloading = true
  245. runtime.clickTime = new Date().getTime()
  246. RuntimeUtils.resetPlayStatus()
  247. runtime.evaluatingTips = false
  248. if (detailState.isPauseRecording) {
  249. evaluating.value = false
  250. // this.startloading = false
  251. startPlay()
  252. return
  253. }
  254. detailState.evaluatings = {}
  255. RuntimeUtils.setCurrentTime(0)
  256. const hint = Toast({
  257. duration: 0,
  258. message: '服务连接中...',
  259. type: 'loading',
  260. })
  261. try {
  262. await connect()
  263. startPlay()
  264. setTimeout(() => {
  265. Toast.clear()
  266. hint.close()
  267. }, 100)
  268. } catch (error) {
  269. runtime.evaluatingStatus = false
  270. Toast.clear()
  271. }
  272. }
  273. const togglePlay = () => {
  274. if (detailState.isPauseRecording) {
  275. evaluating.value = false
  276. startPlay()
  277. return
  278. }
  279. if (evaluating.value) {
  280. stopPlay()
  281. } else {
  282. setPlayer()
  283. }
  284. }
  285. const cancelEvaluating = (data?: IPostMessage) => {
  286. // this.starting = false
  287. if (data?.content.reson) {
  288. // Toast.fail({
  289. // message: data?.content?.reson,
  290. // })
  291. stopPlay()
  292. }
  293. }
  294. const timeupdate = () => {
  295. console.log('播放事件被触发', playUrl.value, evaluating.value)
  296. if (playUrl.value) {
  297. const nowTime = new Date().getTime()
  298. console.log('第一次播放时间', nowTime)
  299. // synced = true
  300. const time = runtime.audiosInstance?.audios[playUrl.value].currentTime
  301. console.log('已播放时长: ', time * 1000)
  302. console.log('不减掉已播放时间: ', nowTime - backtime)
  303. const delayTime = nowTime - backtime - time * 1000
  304. console.log('真正播放延迟', delayTime)
  305. // 蓝牙耳机延迟一点发送消息确保在录音后面
  306. setTimeout(() => {
  307. sendOffsetTime(delayTime)
  308. }, 220)
  309. }
  310. }
  311. /**
  312. * 播放器停止事件
  313. */
  314. const playerStop = () => {
  315. // alert('stop' + this.endloading)
  316. console.log('playerStop播放器停止事件', endloading.value)
  317. if (endloading.value) {
  318. return
  319. }
  320. playStatus.value = 'stop'
  321. endloading.value = true
  322. startButtonShow.value = true
  323. RuntimeUtils.resetPlayStatus()
  324. RuntimeUtils.clearIntervalTimeline()
  325. RuntimeUtils.setCurrentTime(0)
  326. Toast({
  327. duration: 0,
  328. message: '评分中...',
  329. type: 'loading',
  330. })
  331. // const route = (this as any).$route
  332. postMessage(
  333. {
  334. api: 'endEvaluating',
  335. content: {
  336. musicScoreId: useOriginSearch().id,
  337. },
  338. },
  339. (evt) => {
  340. console.log('调用endEvaluating结束', evt)
  341. endloading.value = false
  342. evaluating.value = false
  343. // RuntimeUtils.setCaptureMode()
  344. }
  345. )
  346. }
  347. export const evaluatPlayerStop = playerStop
  348. const endevent = (evt: Event) => {
  349. if ((evt.target as HTMLAudioElement)?.src === playUrl.value && playStatus.value === 'play') {
  350. playerStop()
  351. canSubmit.value = true
  352. }
  353. if (detailState.isAppPlay) {
  354. playerStop()
  355. canSubmit.value = true
  356. }
  357. }
  358. const start = () => {
  359. playStatus.value = 'play'
  360. if (detailState.isPauseRecording) {
  361. postMessage(
  362. {
  363. api: 'resumeRecording',
  364. },
  365. () => {
  366. evaluating.value = true
  367. detailState.isPauseRecording = false
  368. RuntimeUtils.setCaptureMode()
  369. }
  370. )
  371. return
  372. }
  373. console.log('开始录音', new Date().getTime())
  374. postMessage(
  375. {
  376. api: 'startRecording',
  377. },
  378. () => {
  379. console.log('开始录音回调时间', new Date().getTime())
  380. backtime = new Date().getTime()
  381. evaluating.value = true
  382. console.log('midiUrl', detailState.activeDetail?.midiUrl)
  383. if (detailState.activeDetail?.midiUrl) {
  384. setTimeout(() => {
  385. sendOffsetTime(0)
  386. }, 220)
  387. }
  388. }
  389. )
  390. }
  391. /**
  392. * 活动接口,Url中有设置并且仅在学生端提评分交数据
  393. */
  394. const submitEvaluationScore = async (data: any) => {
  395. if (detailState.setting && detailState.setting.mode === 'EVALUATING') {
  396. if (!canSubmit.value) {
  397. Toast('请完成整首曲目评测!')
  398. return
  399. }
  400. try {
  401. await request.post('/activity/evaluationScore', {
  402. requestType: 'json',
  403. data: {
  404. userId: appState.user.userId,
  405. score: data.score,
  406. ...detailState.setting.submitData,
  407. },
  408. })
  409. } catch (error) {}
  410. canSubmit.value = false
  411. }
  412. }
  413. /** 活动使用,只有在全部评测完成后才能提交,避免只是一小节就高分的情况 */
  414. const canSubmit = ref(false)
  415. const sendResult = (evt?: IPostMessage) => {
  416. console.log('评测返回',evt?.content)
  417. if (evt?.content) {
  418. const data = evt?.content?.body
  419. if (evt?.content.header.commond === 'overall') {
  420. // console.log(evt)
  421. Toast.clear()
  422. endResult.value = data
  423. evaluatingShow.value = true
  424. submitEvaluationScore(data)
  425. // this.endloading = false
  426. } else if (evt?.content.header.commond === 'checkDone') {
  427. // 此处已经在校音中单独监听不做处理
  428. } else if (evt?.content.header.commond === 'checking') {
  429. // 此处已经在校音中单独监听不做处理
  430. } else {
  431. const getBeforeNote = (index: number) => {
  432. while (index >= 0) {
  433. const item = detailState.times[index]
  434. if (item.stave) {
  435. return item
  436. }
  437. index--
  438. }
  439. }
  440. const setEvaluatings = (note: any, data: any, dontTransition = false) => {
  441. const startNote = getBoundingBoxByverticalNote(note)
  442. console.log(detailState.evaluatings, startNote)
  443. detailState.evaluatings = {
  444. ...detailState.evaluatings,
  445. [startNote.measureIndex]: {
  446. ...startNote,
  447. ...getLeveByScoreMeasure(data.score),
  448. score: data.score,
  449. dontTransition,
  450. },
  451. }
  452. }
  453. for (let index = 0; index < detailState.times.length; index++) {
  454. let time = detailState.times[index]
  455. if (data.measureRenderIndex == time.noteElement.sourceMeasure.measureListIndex) {
  456. if (!time.stave) {
  457. const ntime = getBeforeNote(index)
  458. // console.log('ntime', ntime)
  459. if (ntime) {
  460. time = ntime
  461. }
  462. }
  463. if (!time.noteElement.tie) {
  464. setEvaluatings(time, data)
  465. } else {
  466. for (const item of time.noteElement.tie.notes) {
  467. const note = getParentNote(item)
  468. if (!note) continue
  469. setEvaluatings(note, data, item.NoteToGraphicalNoteObjectId !== time.noteElement.tie.StartNote?.NoteToGraphicalNoteObjectId)
  470. }
  471. }
  472. break
  473. }
  474. }
  475. }
  476. }
  477. }
  478. const onProgress = () => {
  479. // console.log(runtime.currentTimeNum, detailState.times[detailState.times.length - 1]?.time - 2, detailState.times)
  480. if (runtime.currentTimeNum >= detailState.times[detailState.times.length - 1]?.time - 2) {
  481. canSubmit.value = true
  482. }
  483. }
  484. const cloudMetronome = (evt: any) => {
  485. startButtonShow.value = true
  486. }
  487. export default defineComponent({
  488. name: 'ColexiuButtonEvaluating',
  489. directives: { animate },
  490. setup(props, { expose }) {
  491. onMounted(async () => {
  492. runtime.evaluatingTips = true
  493. detailState.section = []
  494. detailState.sectionStatus = false
  495. RuntimeUtils.changeAllMode()
  496. playUrl.value = runtime.songs.background || (runtime.songs.music as string)
  497. runtime.audiosInstance?.audios[playUrl.value]?.addEventListener('play', timeupdate)
  498. runtime.audiosInstance?.audios[playUrl.value]?.addEventListener('timeupdate', onProgress)
  499. RuntimeUtils.event.on('next-click', playerStop)
  500. RuntimeUtils.event.on('ended', endevent)
  501. listenerMessage('sendResult', sendResult)
  502. listenerMessage('cancelEvaluating', cancelEvaluating)
  503. listenerMessage('cloudTimeUpdae', onProgress)
  504. RuntimeUtils.event.on('tickDestroy', cloudMetronome)
  505. RuntimeUtils.event.on('tickEnd', start)
  506. await RuntimeUtils.pause()
  507. RuntimeUtils.setCurrentTime(0)
  508. })
  509. onBeforeUnmount(() => {
  510. runtime.audiosInstance?.audios[playUrl.value]?.removeEventListener('play', timeupdate)
  511. runtime.audiosInstance?.audios[playUrl.value]?.removeEventListener('timeupdate', onProgress)
  512. RuntimeUtils.event.off('next-click', playerStop)
  513. RuntimeUtils.event.off('ended', endevent)
  514. RuntimeUtils.event.off('tickDestroy', cloudMetronome)
  515. removeListenerMessage('sendResult', sendResult)
  516. removeListenerMessage('cancelEvaluating', cancelEvaluating)
  517. removeListenerMessage('cloudTimeUpdae', onProgress)
  518. RuntimeUtils.event.off('tickEnd', start)
  519. })
  520. expose({
  521. setPlayer,
  522. startPlay,
  523. stopPlay,
  524. togglePlay,
  525. playerStop,
  526. evaluating,
  527. connentLoading,
  528. playStatus,
  529. cancelTheEvaluation
  530. })
  531. return () => {
  532. return (
  533. <>
  534. <Button
  535. v-animate
  536. class={[styles.button, styles.hasText]}
  537. style={{ display: detailState.frozenMode ? 'none' : '' }}
  538. onClick={() => {
  539. runtime.evaluatingStatus = false
  540. if (playStatus.value === 'play' || playStatus.value === 'connecting') {
  541. cancelTheEvaluation()
  542. }
  543. }}
  544. >
  545. <ButtonIcon name="practise" />
  546. <span>练习</span>
  547. </Button>
  548. {/* 评测 */}
  549. <Evaluating data={endResult.value} />
  550. {!evaluating.value ? (
  551. <Teleport to="body" key="StartEvaluating">
  552. <div class={styles.dialogueBox}>
  553. <div class={styles.dialogue}>
  554. <div>
  555. 演奏前请调整好乐器,保证最佳演奏状态。<span class={styles.triangle}></span>
  556. </div>
  557. </div>
  558. <img class={styles.dialogueIcon} src={iconStartEvaluating} />
  559. {/* <dotlottie-player src={StartEvaluating} autoplay loop class={styles.animation} /> */}
  560. </div>
  561. </Teleport>
  562. ) : (
  563. <Teleport to="body" key="Recording">
  564. <div class={styles.dialogueBox}>
  565. <div class={styles.inRadio}>收音中...</div>
  566. <img class={styles.inRadioIcon} src={iconRecord} />
  567. {/* <dotlottie-player src={Recording} autoplay loop class={styles.animation} /> */}
  568. </div>
  569. </Teleport>
  570. )}
  571. </>
  572. )
  573. }
  574. },
  575. })