index.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import { defineComponent, onMounted, onUnmounted, reactive, ref, watch } from "vue";
  2. import state, { gotoNext, resetPlaybackToStart, followBeatPaly, skipNotePlay, initSetPlayRate } from "/src/state";
  3. import { IPostMessage } from "/src/utils/native-message";
  4. import { api_cloudFollowTime, api_cloudToggleFollow } from "/src/helpers/communication";
  5. import { storeData } from "/src/store";
  6. import { audioRecorder } from "./audioRecorder";
  7. import { handleStartTick } from "/src/view/tick";
  8. import { metronomeData } from "/src/helpers/metronome";
  9. import { getDuration } from "/src/helpers/formateMusic";
  10. import { OpenSheetMusicDisplay } from "/osmd-extended/src";
  11. import { browser, getBehaviorId } from "/src/utils";
  12. import { api_musicPracticeRecordSave } from "../../page-instrument/api";
  13. import { getQuery } from "/src/utils/queryString";
  14. const query: any = getQuery();
  15. export const followData = reactive({
  16. list: [] as any, // 频率列表
  17. index: 0,
  18. start: false,
  19. rendered: false,
  20. /** 麦克风权限 */
  21. earphone: false,
  22. isBeginMask: false, // 倒计时和系统节拍器时候的遮罩,防止用户点击
  23. dontAccredit: true, // 没有开启麦克风权限,不需要调用结束收音的api
  24. practiceStart: false,
  25. });
  26. // 记录跟练时长
  27. const handleRecord = (total: number) => {
  28. if (query.isCbs || state.systemType === 'web') return
  29. if (total < 0) total = 0;
  30. const totalTime = total / 1000;
  31. const body = {
  32. clientType: storeData.user.clientType,
  33. musicSheetId: state.examSongId,
  34. sysMusicScoreId: state.examSongId,
  35. feature: "FOLLOW_UP_TRAINING",
  36. practiceSource: "FOLLOW_UP_TRAINING",
  37. playTime: totalTime,
  38. deviceType: browser().android ? "ANDROID" : "IOS",
  39. behaviorId: getBehaviorId(),
  40. };
  41. api_musicPracticeRecordSave(body);
  42. };
  43. /** 点击跟练模式 */
  44. export const toggleFollow = (notCancel = true) => {
  45. state.modeType = state.modeType === "follow" ? "practise" : "follow";
  46. // 取消跟练
  47. if (!notCancel) {
  48. followData.start = false;
  49. followData.practiceStart = false;
  50. // 开启了麦克风授权,才需要调用结束收音
  51. if (storeData.isApp && !followData.dontAccredit) {
  52. openToggleRecord(false);
  53. }
  54. }
  55. };
  56. const noteFrequency = ref(0);
  57. const audioFrequency = ref(0);
  58. const followTime = ref(0);
  59. // 切换录音
  60. const openToggleRecord = async (open: boolean = true) => {
  61. if (!open) api_cloudToggleFollow(open ? "start" : "end");
  62. // 记录跟练时长
  63. if (open) {
  64. followTime.value = Date.now();
  65. } else {
  66. const playTime = Date.now() - followTime.value;
  67. if (followTime.value !== 0 && playTime > 0) {
  68. handleRecord(playTime);
  69. followTime.value = 0;
  70. }
  71. }
  72. if (!storeData.isApp) {
  73. const openState = await audioRecorder?.toggleRecord(open);
  74. // 开启录音失败
  75. if (!openState && followData.start) {
  76. followData.earphone = true;
  77. followData.start = false;
  78. followData.practiceStart = false;
  79. }
  80. }
  81. };
  82. // 清除音符状态
  83. const onClear = () => {
  84. state.times.forEach((item: any) => {
  85. const note: HTMLElement = document.querySelector(`div[data-vf=vf${item.id}]`)!;
  86. if (note) {
  87. note.classList.remove("follow-up", "follow-down", "follow-error", "follow-success");
  88. }
  89. const _note: HTMLElement = document.getElementById(`vf-${item.id}`)!;
  90. const stemEl = document.getElementById(`vf-${item.id}-stem`);
  91. if (_note) {
  92. _note.classList.remove("follow-up", "follow-down", "follow-success");
  93. stemEl?.classList.remove("follow-up", "follow-down", "follow-success");
  94. }
  95. });
  96. };
  97. /** 开始跟练 */
  98. export const handleFollowStart = async () => {
  99. followData.isBeginMask = true
  100. checking = false;
  101. const res = await api_cloudToggleFollow("start");
  102. // 用户没有授权,需要重置状态
  103. if (res?.content?.reson) {
  104. followData.isBeginMask = false
  105. followData.start = false;
  106. followData.practiceStart = false;
  107. } else {
  108. followData.dontAccredit = false;
  109. state.hasFollowResult = true;
  110. // 从头开始跟练,跟练模式开始前,增加播放系统节拍器
  111. if (state.activeNoteIndex === 0) {
  112. const tickend = await handleStartTick();
  113. // console.log("🚀 ~ tickend:", tickend)
  114. // 节拍器返回false, 取消播放
  115. if (!tickend) {
  116. followData.isBeginMask = false
  117. followData.start = false;
  118. followData.practiceStart = false;
  119. return false;
  120. }
  121. }
  122. onClear();
  123. followData.isBeginMask = false
  124. followData.start = true;
  125. followData.practiceStart = true;
  126. // followData.index = 0;
  127. followData.index = state.activeNoteIndex;
  128. followData.list = [];
  129. initSetPlayRate();
  130. // resetPlaybackToStart();
  131. openToggleRecord(true);
  132. getNoteIndex();
  133. const duration: any = getDuration(state.osmd as unknown as OpenSheetMusicDisplay);
  134. metronomeData.totalNumerator = duration.numerator || 2
  135. metronomeData.followAudioIndex = 1
  136. state.beatStartTime = 0
  137. followBeatPaly();
  138. }
  139. };
  140. /** 结束跟练 */
  141. export const handleFollowEnd = () => {
  142. onClear();
  143. followData.start = false;
  144. followData.practiceStart = false;
  145. state.hasFollowResult = false
  146. openToggleRecord(false);
  147. followData.index = 0;
  148. console.log("结束");
  149. };
  150. // 清除当前音符右侧的音符的颜色状态
  151. const clearRightNoteColor = () => {
  152. const noteId = state.times[state.activeNoteIndex]?.id;
  153. const leftVal = document.getElementById(`vf-${noteId}`)?.getBoundingClientRect()?.left || 0;
  154. state.times.forEach((item: any) => {
  155. const note: HTMLElement = document.getElementById(`vf-${item.id}`)!;
  156. if (note?.getBoundingClientRect()?.left >= leftVal) {
  157. note.classList.remove("follow-up", "follow-down", "follow-error", "follow-success");
  158. }
  159. });
  160. }
  161. /**
  162. * 2024.6.17 新增自动结束跟练模式功能
  163. * 如果跟练模式,唱完了最后一个有频率的音符,需要自动结束掉跟练模式
  164. * 需要判断当前音符的后面是否都是休止音符(休止音符的频率都是-1),或者是否是最后一个音符了
  165. */
  166. const autoEndFollow = () => {
  167. if (followData.index >= state.times.length) {
  168. handleFollowEnd()
  169. return
  170. }
  171. let nextIndex = followData.index + 1;
  172. const rightTimes = state.times.slice(followData.index,state.times.length)
  173. // 后面的音符是否都是休止音符
  174. const isAllRest = !rightTimes.some((item: any) => item.frequency > 1);
  175. if (isAllRest && state.times[followData.index].frequency < 1) {
  176. handleFollowEnd()
  177. return
  178. }
  179. clearRightNoteColor();
  180. }
  181. // 下一个
  182. const next = () => {
  183. if (followData.index < state.times.length) {
  184. gotoNext(state.times[followData.index]);
  185. }
  186. autoEndFollow();
  187. };
  188. // 获取当前音符
  189. const getNoteIndex = (): any => {
  190. const item = state.times[followData.index];
  191. if (item.frequency <= 0) {
  192. followData.index = followData.index + 1;
  193. next();
  194. return getNoteIndex();
  195. }
  196. noteFrequency.value = item.frequency;
  197. // state.fixedKey = item.realKey;
  198. return {
  199. id: item.id,
  200. min: item.frequency - (item.frequency - item.prevFrequency) * 0.5,
  201. max: item.frequency + (item.nextFrequency - item.frequency) * 0.5,
  202. duration: item.duration,
  203. baseFrequency: item.frequency,
  204. };
  205. };
  206. let checking = false;
  207. /** 录音回调 */
  208. const onFollowTime = (evt?: IPostMessage) => {
  209. const frequency: number = evt?.content?.frequency;
  210. // 没有开始, 不处理
  211. if (!followData.start) return;
  212. // console.log('频率', frequency)
  213. if (frequency > 0) {
  214. audioFrequency.value = frequency;
  215. // data.list.push(frequency)
  216. checked();
  217. }
  218. };
  219. let startTime = 0;
  220. const checked = () => {
  221. if (checking) return;
  222. checking = true;
  223. const item = getNoteIndex();
  224. // 降噪处理, 频率低于 当前音的50% 时, 不处理
  225. if (audioFrequency.value < item.baseFrequency * 0.5) {
  226. checking = false;
  227. return;
  228. }
  229. if (audioFrequency.value >= item.min && audioFrequency.value <= item.max) {
  230. if (startTime === 0) {
  231. startTime = Date.now();
  232. } else {
  233. const playTime = (Date.now() - startTime) / 1000;
  234. // console.log('时长', playTime, item.duration / 2)
  235. if (playTime >= item.duration * 0.6) {
  236. startTime = 0;
  237. followData.index = followData.index + 1;
  238. setColor(item, "", true);
  239. setTimeout(() => {
  240. next();
  241. checking = false;
  242. }, 3000);
  243. return;
  244. }
  245. }
  246. // console.log("频率对了", item.min, audioFrequency.value, item.max, item.duration);
  247. }
  248. // console.log("频率不对", item.min, audioFrequency.value, item.max, item.baseFrequency);
  249. setColor(item, audioFrequency.value > item.baseFrequency ? "follow-up" : "follow-down");
  250. checking = false;
  251. };
  252. const setColor = (item: any, state: "follow-up" | "follow-down" | "", isRight = false) => {
  253. const note: HTMLElement = document.querySelector(`div[data-vf=vf${item.id}]`)!;
  254. if (note) {
  255. note.classList.remove("follow-up", "follow-down", "follow-error", "follow-success");
  256. if (isRight) {
  257. note.classList.add("follow-success");
  258. } else {
  259. note.classList.add("follow-error", state);
  260. }
  261. }
  262. const _note: HTMLElement = document.getElementById(`vf-${item.id}`)!;
  263. if (_note) {
  264. const stemEl = document.getElementById(`vf-${item.id}-stem`)
  265. _note.classList.remove("follow-up", "follow-down");
  266. stemEl?.classList.remove("follow-up", "follow-down","follow-success");
  267. if (state) {
  268. _note.classList.add(state);
  269. stemEl?.classList.add(state)
  270. }
  271. if (isRight) {
  272. _note.classList.add("follow-success");
  273. stemEl?.classList.add("follow-success")
  274. }
  275. }
  276. };
  277. // 进度跟练,点击某个音符开始跟练
  278. export const skipNotePractice = () => {
  279. followData.index = state.activeNoteIndex
  280. // 清除其它音符的错误提示
  281. const noteFollows: HTMLElement[] = Array.from(document.querySelectorAll(".follow-error"));
  282. noteFollows.forEach((noteFollow) => {
  283. noteFollow?.classList.remove("follow-up", "follow-down", "follow-error");
  284. })
  285. clearRightNoteColor();
  286. }
  287. // 移动到对应音符的位置
  288. watch(
  289. () => followData.index,
  290. () => {
  291. skipNotePlay(followData.index);
  292. }
  293. );
  294. export default defineComponent({
  295. name: "follow",
  296. setup() {
  297. onMounted(async () => {
  298. /** 如果是PC端 */
  299. if (!storeData.isApp) {
  300. const canRecorder = await audioRecorder.checkSupport();
  301. if (canRecorder) {
  302. audioRecorder.init();
  303. audioRecorder.progress = (frequency: number) => {
  304. onFollowTime({ api: "", content: { frequency } });
  305. };
  306. } else {
  307. followData.earphone = true;
  308. }
  309. } else {
  310. api_cloudFollowTime(onFollowTime);
  311. }
  312. console.log("进入跟练模式");
  313. });
  314. onUnmounted(() => {
  315. // api_cloudFollowTime(onFollowTime, false);
  316. resetPlaybackToStart();
  317. onClear();
  318. // 开启了麦克风授权,才需要调用结束收音
  319. if (storeData.isApp && !followData.dontAccredit) {
  320. openToggleRecord(false);
  321. }
  322. console.log("退出跟练模式");
  323. });
  324. return () => <div></div>;
  325. },
  326. });