index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. import { Transition, defineComponent, onMounted, reactive, watch } from "vue";
  2. import { connectWebsocket, evaluatingData, handleEndBegin, handleStartBegin, handleStartEvaluat, handleViewReport, startCheckDelay } from "/src/view/evaluating";
  3. import Earphone from "./earphone";
  4. import styles from "./index.module.less";
  5. import SoundEffect from "./sound-effect";
  6. import state, { handleRessetState } from "/src/state";
  7. import { storeData } from "/src/store";
  8. import { browser } from "/src/utils";
  9. import { getNoteByMeasuresSlursStart } from "/src/helpers/formateMusic";
  10. import { Icon, Popup, showToast } from "vant";
  11. import EvaluatResult from "./evaluat-result";
  12. import EvaluatAudio from "./evaluat-audio";
  13. import { api_getDeviceDelay, api_openAdjustRecording, api_proxyServiceMessage, api_videoUpdate, getEarphone } from "/src/helpers/communication";
  14. import EvaluatShare from "./evaluat-share";
  15. import { Vue3Lottie } from "vue3-lottie";
  16. import startData from "./data/start.json";
  17. import startingData from "./data/starting.json";
  18. import iconTastBg from "./icons/task-bg.svg";
  19. import iconEvaluat from "./icons/evaluating.json";
  20. import { api_musicPracticeRecordVideoUpload } from "../api";
  21. import DelayCheck from "./delay-check";
  22. import { headTopData } from "../header-top/index";
  23. import { getQuery } from "/src/utils/queryString";
  24. // frequency 频率, amplitude 振幅, decibels 分贝
  25. type TCriteria = "frequency" | "amplitude" | "decibels";
  26. /**
  27. * 节拍器时长
  28. * 评测模式时,应该传节拍器时长
  29. * 阶段评测时,判断是否从第一小节开始,并且曲子本身含有节拍器,需要传节拍器时长,否则传0
  30. */
  31. let actualBeatLength = 0
  32. let calculateInfo: any = {}
  33. export default defineComponent({
  34. name: "evaluat-model",
  35. setup() {
  36. const query = getQuery();
  37. const evaluatModel = reactive({
  38. tips: true,
  39. evaluatUpdateAudio: false,
  40. isSaveVideo: state.setting.camera && state.setting.saveToAlbum,
  41. shareMode: false,
  42. });
  43. /**
  44. * 检测返回
  45. */
  46. const handleDelayBack = () => {
  47. if (query.workRecord) {
  48. evaluatingData.soundEffectMode = false;
  49. } else {
  50. evaluatingData.soundEffectMode = false;
  51. handleRessetState();
  52. headTopData.modeType = "init";
  53. }
  54. }
  55. /**
  56. * 执行检测
  57. */
  58. const handlePerformDetection = async () => {
  59. console.log(evaluatingData.checkStep, evaluatingData, "检测");
  60. // 检测完成不检测了
  61. if (evaluatingData.checkEnd) return;
  62. // 延迟检测
  63. if (evaluatingData.checkStep === 0) {
  64. evaluatingData.checkStep = 10;
  65. // 没有设备延迟数据 或 开启了效音 显示检测组件,并持续检测耳机状态
  66. if (state.setting.soundEffect) {
  67. evaluatingData.soundEffectMode = true;
  68. return;
  69. }
  70. // 判断只有开始了设备检测之后才去调用api
  71. if (state.setting.soundEffect) {
  72. const delayData = await api_getDeviceDelay();
  73. // console.log("🚀 ~ delayTime:", delayData);
  74. if (delayData && delayData.content?.value < 0) {
  75. evaluatingData.soundEffectMode = true;
  76. return;
  77. }
  78. }
  79. handlePerformDetection();
  80. return;
  81. }
  82. // 效验完成
  83. if (evaluatingData.checkStep === 10) {
  84. evaluatingData.checkEnd = true;
  85. console.log("检测结束,生成数据");
  86. handleConnect();
  87. }
  88. };
  89. const browserInfo = browser();
  90. /** 是否是节奏练习 */
  91. const isRhythmicExercises = () => {
  92. const examSongName = state.examSongName || "";
  93. return examSongName.indexOf("节奏练习") > -1;
  94. };
  95. /** 获取评测标准 */
  96. const getEvaluationCriteria = () => {
  97. let criteria: TCriteria = "frequency";
  98. // 声部打击乐
  99. if ([23, 113, 121].includes(state.subjectId)) {
  100. criteria = "amplitude";
  101. } else if (isRhythmicExercises()) {
  102. // 分类为节奏练习
  103. criteria = "decibels";
  104. }
  105. return criteria;
  106. };
  107. /** 生成评测曲谱数据 */
  108. const formatTimes = () => {
  109. let starTime = 0
  110. let ListenMode = false;
  111. let dontEvaluatingMode = false;
  112. let skip = false;
  113. const datas = [];
  114. let selectTimes = state.times
  115. let unitTestIdx = 0
  116. let preTime = 0
  117. let preTimes = []
  118. // 系统节拍器时长
  119. actualBeatLength = Math.round(state.times[0].fixtime * 1000 / 1)
  120. // 如果是阶段评测,选取该阶段的times
  121. if (state.isSelectMeasureMode && state.section.length) {
  122. const startIndex = state.times.findIndex(
  123. (n: any) => n.noteId == state.section[0].noteId
  124. )
  125. let endIndex = state.times.findIndex(
  126. (n: any) => n.noteId == state.section[1].noteId
  127. )
  128. endIndex = endIndex < state.section[1].i ? state.section[1].i : endIndex
  129. if (startIndex > 1) {
  130. // firstNoteTime应该取预备小节的第一个音符的开始播放的时间
  131. const idx = startIndex - 1 - (state.times[startIndex-1].si)
  132. preTime = state.times[idx] ? state.times[idx].time * 1000 : 0
  133. }
  134. actualBeatLength = startIndex == 0 && !state.needTick ? actualBeatLength : 0
  135. selectTimes = state.times.filter((n: any, index: number) => {
  136. return index >= startIndex && index <= endIndex
  137. })
  138. preTimes = state.times.filter((n: any, index: number) => {
  139. return index < startIndex
  140. })
  141. unitTestIdx = startIndex
  142. starTime = selectTimes[0].sourceRelativeTime || selectTimes[0].relativeTime
  143. }
  144. // 阶段评测beatLength需要加上预备小节的持续时长
  145. actualBeatLength = preTimes.length ? actualBeatLength + preTimes[preTimes.length - 1].relaMeasureLength * 1000 : actualBeatLength
  146. let firstNoteTime = unitTestIdx > 1 ? preTime : 0
  147. let measureIndex = -1
  148. let recordMeasure = -1
  149. for (let index = 0; index < selectTimes.length; index++) {
  150. const item = selectTimes[index];
  151. const note = getNoteByMeasuresSlursStart(item);
  152. // #8701 bug: 评测模式,是以曲谱本身的速度进行评测,所以rate取1,不需要转换
  153. // const rate = state.speed / state.originSpeed;
  154. const rate = 1;
  155. const difftime = item.difftime;
  156. const start = difftime + (item.sourceRelativeTime || item.relativeTime) - starTime;
  157. const end = difftime + (item.sourceRelaEndtime || item.relaEndtime) - starTime;
  158. const isStaccato = note.noteElement.voiceEntry.isStaccato();
  159. const noteRate = isStaccato ? 0.5 : 1;
  160. if (note.formatLyricsEntries.contains("Play") || note.formatLyricsEntries.contains("Play...")) {
  161. ListenMode = false;
  162. }
  163. if (note.formatLyricsEntries.contains("Listen")) {
  164. ListenMode = true;
  165. }
  166. if (note.formatLyricsEntries.contains("纯律结束")) {
  167. dontEvaluatingMode = false;
  168. }
  169. if (note.formatLyricsEntries.contains("纯律")) {
  170. dontEvaluatingMode = true;
  171. }
  172. const nextNote = selectTimes[index + 1];
  173. // console.log("noteinfo", note.noteElement.isRestFlag && !!note.stave && !!nextNote)
  174. if (skip && (note.stave || !item.noteElement.isRestFlag || (nextNote && !nextNote.noteElement.isRestFlag))) {
  175. skip = false;
  176. }
  177. if (note.noteElement.isRestFlag && !!note.stave && !!nextNote && nextNote.noteElement.isRestFlag) {
  178. skip = true;
  179. }
  180. // console.log(note.measureOpenIndex, item.measureOpenIndex, note);
  181. // console.log("skip", skip)
  182. // console.log(end,start,rate,noteRate, '评测')
  183. if (note.measureOpenIndex != recordMeasure) {
  184. measureIndex++
  185. recordMeasure = note.measureOpenIndex
  186. }
  187. const data = {
  188. timeStamp: (start * 1000) / rate,
  189. duration: ((end * 1000) / rate - (start * 1000) / rate) * noteRate,
  190. frequency: item.frequency,
  191. nextFrequency: item.nextFrequency,
  192. prevFrequency: item.prevFrequency,
  193. // 重复的情况index会自然累加,render的index是谱面渲染的index
  194. measureIndex: measureIndex,
  195. measureRenderIndex: item.measureListIndex,
  196. dontEvaluating: ListenMode || dontEvaluatingMode || item.skipMode,
  197. musicalNotesIndex: index,
  198. denominator: note.noteElement?.Length.denominator,
  199. isOrnament: !!note?.voiceEntry?.ornamentContainer,
  200. };
  201. datas.push(data);
  202. }
  203. return {
  204. datas,
  205. firstNoteTime
  206. }
  207. };
  208. /** 连接websocket */
  209. const handleConnect = async () => {
  210. const behaviorId = localStorage.getItem("behaviorId") || undefined;
  211. const rate = state.speed / state.originSpeed;
  212. calculateInfo = formatTimes()
  213. const content = {
  214. musicXmlInfos: calculateInfo.datas,
  215. subjectId: state.subjectId,
  216. detailId: state.detailId,
  217. examSongId: state.examSongId,
  218. xmlUrl: state.xmlUrl,
  219. partIndex: state.partIndex,
  220. behaviorId,
  221. platform: browserInfo.ios ? "IOS" : browserInfo.android ? "ANDROID" : "WEB",
  222. clientId: storeData.platformType === "STUDENT" ? "student" : storeData.platformType === "TEACHER" ? "teacher" : "education",
  223. hertz: state.setting.frequency,
  224. reactionTimeMs: state.setting.reactionTimeMs,
  225. speed: state.speed,
  226. heardLevel: state.setting.evaluationDifficulty,
  227. // beatLength: Math.round((state.fixtime * 1000) / rate),
  228. beatLength: actualBeatLength,
  229. evaluationCriteria: getEvaluationCriteria(),
  230. };
  231. await connectWebsocket(content);
  232. // state.playSource = "music";
  233. };
  234. /** 评测结果按钮处理 */
  235. const handleEvaluatResult = (type: "practise" | "tryagain" | "look" | "share" | "update") => {
  236. if (type === "update") {
  237. if (evaluatingData.resultData?.recordIdStr || evaluatingData.resultData?.recordId) {
  238. // 上传云端
  239. // evaluatModel.evaluatUpdateAudio = true;
  240. api_openAdjustRecording({
  241. recordId: evaluatingData.resultData?.recordIdStr || evaluatingData.resultData?.recordId,
  242. title: state.examSongName || "曲谱演奏",
  243. coverImg: state.coverImg,
  244. });
  245. return;
  246. }
  247. } else if (type === "share") {
  248. // 分享
  249. evaluatModel.shareMode = true;
  250. return;
  251. } else if (type === "look") {
  252. // 跳转
  253. handleViewReport("recordId", "instrument");
  254. return;
  255. } else if (type === "practise") {
  256. // 去练习
  257. handleStartEvaluat();
  258. } else if (type === "tryagain") {
  259. // 再来一次
  260. startBtnHandle()
  261. }
  262. evaluatingData.resulstMode = false;
  263. };
  264. /** 上传音视频 */
  265. const hanldeUpdateVideoAndAudio = async (update = false) => {
  266. if (!update) {
  267. evaluatModel.evaluatUpdateAudio = false;
  268. return;
  269. }
  270. if (state.setting.camera && state.setting.saveToAlbum) {
  271. evaluatModel.evaluatUpdateAudio = false;
  272. api_videoUpdate((res: any) => {
  273. if (res) {
  274. if (res?.content?.type === "success") {
  275. handleSaveResult({
  276. id: evaluatingData.resultData?.recordId,
  277. videoFilePath: res?.content?.filePath,
  278. });
  279. } else if (res?.content?.type === "error") {
  280. showToast({
  281. message: res.content?.message || "上传失败",
  282. });
  283. }
  284. }
  285. });
  286. return;
  287. }
  288. evaluatModel.evaluatUpdateAudio = false;
  289. showToast("上传成功");
  290. };
  291. const handleSaveResult = async (_body: any) => {
  292. await api_musicPracticeRecordVideoUpload(_body);
  293. showToast("上传成功");
  294. };
  295. const startBtnHandle = async () => {
  296. // 检测APP端socket状态
  297. const res: any = await startCheckDelay();
  298. if (res?.checked) {
  299. handleConnect();
  300. handleStartBegin(calculateInfo.firstNoteTime);
  301. }
  302. }
  303. onMounted(() => {
  304. evaluatingData.isDisabledPlayMusic = true;
  305. handlePerformDetection();
  306. });
  307. return () => (
  308. <div>
  309. <Transition name="pop-center">
  310. {evaluatingData.websocketState && !evaluatingData.startBegin && evaluatingData.checkEnd && (
  311. <div class={styles.startBtn} onClick={() => {
  312. startBtnHandle()
  313. }}>
  314. <img src={iconEvaluat.evaluatingStart} />
  315. </div>
  316. )}
  317. </Transition>
  318. <Transition name="pop-center">
  319. {evaluatingData.websocketState && evaluatingData.startBegin && (
  320. <div class={styles.endBtn} onClick={() => handleEndBegin()}>
  321. <img src={iconEvaluat.evaluatingEnd} />
  322. </div>
  323. )}
  324. </Transition>
  325. <div style={{ display: !evaluatingData.startBegin ? "" : "none" }} class={styles.dialogueBox} key="start">
  326. <div class={styles.dialogue}>
  327. <img class={styles.dialoguebg} src={iconTastBg} />
  328. <div>演奏前请调整好乐器,保证最佳演奏状态。</div>
  329. </div>
  330. <Vue3Lottie class={styles.dialogueIcon} animationData={startData}></Vue3Lottie>
  331. </div>
  332. <div style={{ display: evaluatingData.startBegin ? "" : "none" }} class={styles.dialogueBox} key="start">
  333. <div class={styles.dialogueing}>收音中...</div>
  334. <Vue3Lottie class={styles.dialogueIcon} animationData={startingData}></Vue3Lottie>
  335. </div>
  336. {evaluatingData.soundEffectMode && (
  337. <DelayCheck
  338. onClose={() => {
  339. evaluatingData.soundEffectMode = false;
  340. handlePerformDetection();
  341. }}
  342. onBack={() => handleDelayBack()}
  343. />
  344. )}
  345. <Popup teleport="body" closeOnClickOverlay={false} class={["popup-custom", "van-scale"]} transition="van-scale" v-model:show={evaluatingData.earphoneMode}>
  346. <Earphone
  347. onClose={() => {
  348. evaluatingData.earphoneMode = false;
  349. handlePerformDetection();
  350. }}
  351. />
  352. </Popup>
  353. {/* <Popup
  354. teleport="body"
  355. closeOnClickOverlay={false}
  356. class={["popup-custom", "van-scale"]}
  357. transition="van-scale"
  358. v-model:show={evaluatingData.soundEffectMode}
  359. >
  360. <SoundEffect
  361. onClose={(value: any) => {
  362. evaluatingData.soundEffectMode = false;
  363. if (value) {
  364. state.setting.soundEffect = false;
  365. }
  366. handleEndSoundCheck();
  367. handlePerformDetection();
  368. }}
  369. />
  370. </Popup> */}
  371. <Popup teleport="body" closeOnClickOverlay={false} class={["popup-custom", "van-scale"]} transition="van-scale" v-model:show={evaluatingData.resulstMode}>
  372. <EvaluatResult onClose={handleEvaluatResult} />
  373. </Popup>
  374. <Popup teleport="body" closeOnClickOverlay={false} class={["popup-custom", "van-scale"]} transition="van-scale" v-model:show={evaluatModel.evaluatUpdateAudio}>
  375. <EvaluatAudio onClose={hanldeUpdateVideoAndAudio} />
  376. </Popup>
  377. <Popup teleport="body" class={["popup-custom", "van-scale"]} transition="van-scale" v-model:show={evaluatModel.shareMode}>
  378. <EvaluatShare onClose={() => (evaluatModel.shareMode = false)} />
  379. </Popup>
  380. </div>
  381. );
  382. },
  383. });