index.tsx 11 KB

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