update 优化录音功能模块的代码和火山模块
This commit is contained in:
		
							
								
								
									
										355
									
								
								web/src/apps/muse/voice/store/voiceStore.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										355
									
								
								web/src/apps/muse/voice/store/voiceStore.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,355 @@
 | 
			
		||||
import { create } from 'zustand';
 | 
			
		||||
import { devtools, persist } from 'zustand/middleware';
 | 
			
		||||
import { Speak, getDayOfYear, CreateSpeakData } from '../modules/speak-db/speak';
 | 
			
		||||
import { speakService } from '../modules/speak-db/speak-service';
 | 
			
		||||
import { getText } from '../modules/text';
 | 
			
		||||
import { useSettingStore } from './settingStore';
 | 
			
		||||
 | 
			
		||||
interface VoiceState {
 | 
			
		||||
  // 状态数据
 | 
			
		||||
  voiceList: Speak[];
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
  error: string | null;
 | 
			
		||||
  currentDay: number;
 | 
			
		||||
 | 
			
		||||
  // 动作方法
 | 
			
		||||
  initialize: () => Promise<void>;
 | 
			
		||||
  addVoice: (url: string, duration: number, audioBlob?: Blob) => Promise<Speak>;
 | 
			
		||||
  updateVoice: (id: string, updates: Partial<Speak>) => Promise<void>;
 | 
			
		||||
  deleteVoice: (id: string) => Promise<void>;
 | 
			
		||||
  recognizeVoice: (id: string) => Promise<string>;
 | 
			
		||||
  clearTodayVoices: () => Promise<void>;
 | 
			
		||||
  generateAudioUrls: () => Promise<void>;
 | 
			
		||||
  refreshList: () => Promise<void>;
 | 
			
		||||
  setError: (error: string | null) => void;
 | 
			
		||||
  setLoading: (loading: boolean) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 辅助函数:将 Blob 转换为 base64 字符串
 | 
			
		||||
const blobToBase64 = (blob: Blob): Promise<string> => {
 | 
			
		||||
  return new Promise((resolve, reject) => {
 | 
			
		||||
    const reader = new FileReader();
 | 
			
		||||
    reader.onload = () => {
 | 
			
		||||
      const result = reader.result as string;
 | 
			
		||||
      // 移除 data URL 前缀,只保留 base64 数据
 | 
			
		||||
      resolve(result.split(',')[1] || result);
 | 
			
		||||
    };
 | 
			
		||||
    reader.onerror = reject;
 | 
			
		||||
    reader.readAsDataURL(blob);
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 辅助函数:将 base64 字符串转换为 Blob URL
 | 
			
		||||
const base64ToUrl = (base64: string, mimeType: string = 'audio/wav'): string => {
 | 
			
		||||
  try {
 | 
			
		||||
    // 如果已经是 blob URL,直接返回
 | 
			
		||||
    if (base64.startsWith('blob:')) {
 | 
			
		||||
      return base64;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 将 base64 转换为 ArrayBuffer
 | 
			
		||||
    const binaryString = window.atob(base64);
 | 
			
		||||
    const bytes = new Uint8Array(binaryString.length);
 | 
			
		||||
    for (let i = 0; i < binaryString.length; i++) {
 | 
			
		||||
      bytes[i] = binaryString.charCodeAt(i);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 创建 Blob 和 URL
 | 
			
		||||
    const blob = new Blob([bytes], { type: mimeType });
 | 
			
		||||
    return URL.createObjectURL(blob);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('转换 base64 到 URL 失败:', error);
 | 
			
		||||
    return '';
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useVoiceStore = create<VoiceState>()(
 | 
			
		||||
  devtools(
 | 
			
		||||
    (set, get) => ({
 | 
			
		||||
      // 初始状态
 | 
			
		||||
      voiceList: [],
 | 
			
		||||
      isLoading: false,
 | 
			
		||||
      error: null,
 | 
			
		||||
      currentDay: getDayOfYear(),
 | 
			
		||||
 | 
			
		||||
      // 初始化:从 IndexedDB 获取当天的记录
 | 
			
		||||
      initialize: async () => {
 | 
			
		||||
        const { setLoading, setError, generateAudioUrls } = get();
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
          setLoading(true);
 | 
			
		||||
          setError(null);
 | 
			
		||||
 | 
			
		||||
          // 初始化 speak service
 | 
			
		||||
          await speakService.init();
 | 
			
		||||
 | 
			
		||||
          // 获取当天的语音记录
 | 
			
		||||
          const currentDay = getDayOfYear();
 | 
			
		||||
          const todayVoices = await speakService.getSpeaksByDay(currentDay);
 | 
			
		||||
 | 
			
		||||
          set({
 | 
			
		||||
            voiceList: todayVoices,
 | 
			
		||||
            currentDay: currentDay
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          // 为获取到的记录生成 audio URLs
 | 
			
		||||
          await generateAudioUrls();
 | 
			
		||||
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error('初始化语音列表失败:', error);
 | 
			
		||||
          setError(error instanceof Error ? error.message : '初始化失败');
 | 
			
		||||
        } finally {
 | 
			
		||||
          setLoading(false);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      // 添加新的语音记录
 | 
			
		||||
      addVoice: async (url: string, duration: number, audioBlob?: Blob) => {
 | 
			
		||||
        const { setError } = get();
 | 
			
		||||
        const autoRecognize = useSettingStore.getState().autoRecognize;
 | 
			
		||||
        try {
 | 
			
		||||
          setError(null);
 | 
			
		||||
 | 
			
		||||
          let fileData: string | undefined;
 | 
			
		||||
 | 
			
		||||
          // 如果提供了 audioBlob,将其转换为 base64 保存到 IndexedDB
 | 
			
		||||
          if (audioBlob) {
 | 
			
		||||
            fileData = await blobToBase64(audioBlob);
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // 创建语音记录(不保存 url,只保存 base64 数据)
 | 
			
		||||
          const speakData = {
 | 
			
		||||
            duration: Math.ceil(duration),
 | 
			
		||||
            file: fileData, // 保存 base64 数据而不是 url
 | 
			
		||||
            day: getDayOfYear(),
 | 
			
		||||
            no: 0, // 将由 service 自动生成
 | 
			
		||||
            timestamp: Date.now(),
 | 
			
		||||
            type: 'normal' as const,
 | 
			
		||||
            text: '', // 初始为空
 | 
			
		||||
          };
 | 
			
		||||
          if (autoRecognize) {
 | 
			
		||||
            speakData.text = await getText(fileData || '').then(res => res.text);
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // 保存到 IndexedDB(不包含 url)
 | 
			
		||||
          const newSpeak = await speakService.createSpeakAuto(speakData);
 | 
			
		||||
 | 
			
		||||
          // 为新记录生成 URL 并添加到状态
 | 
			
		||||
          const speakWithUrl = {
 | 
			
		||||
            ...newSpeak,
 | 
			
		||||
            url: newSpeak.file ? base64ToUrl(newSpeak.file) : url
 | 
			
		||||
          };
 | 
			
		||||
 | 
			
		||||
          set(state => ({
 | 
			
		||||
            voiceList: [...state.voiceList, speakWithUrl]
 | 
			
		||||
          }));
 | 
			
		||||
 | 
			
		||||
          return speakWithUrl;
 | 
			
		||||
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error('添加语音记录失败:', error);
 | 
			
		||||
          setError(error instanceof Error ? error.message : '添加失败');
 | 
			
		||||
          throw error;
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      // 更新语音记录
 | 
			
		||||
      updateVoice: async (id: string, updates: Partial<Speak>) => {
 | 
			
		||||
        const { setError } = get();
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
          setError(null);
 | 
			
		||||
 | 
			
		||||
          // 从更新数据中移除 url,因为 url 不应该保存到 IndexedDB
 | 
			
		||||
          const { url, ...updatesWithoutUrl } = updates;
 | 
			
		||||
 | 
			
		||||
          // 更新 IndexedDB 中的记录
 | 
			
		||||
          const updatedSpeak = await speakService.updateSpeak(id, updatesWithoutUrl);
 | 
			
		||||
 | 
			
		||||
          // 更新状态中的记录
 | 
			
		||||
          set(state => ({
 | 
			
		||||
            voiceList: state.voiceList.map(voice => {
 | 
			
		||||
              if (voice.id === id) {
 | 
			
		||||
                const updated = { ...voice, ...updates };
 | 
			
		||||
                // 如果更新了 file 数据,重新生成 URL
 | 
			
		||||
                if (updatesWithoutUrl.file) {
 | 
			
		||||
                  updated.url = base64ToUrl(updatesWithoutUrl.file);
 | 
			
		||||
                }
 | 
			
		||||
                return updated;
 | 
			
		||||
              }
 | 
			
		||||
              return voice;
 | 
			
		||||
            })
 | 
			
		||||
          }));
 | 
			
		||||
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error('更新语音记录失败:', error);
 | 
			
		||||
          setError(error instanceof Error ? error.message : '更新失败');
 | 
			
		||||
          throw error;
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      // 删除语音记录
 | 
			
		||||
      deleteVoice: async (id: string) => {
 | 
			
		||||
        const { setError } = get();
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
          setError(null);
 | 
			
		||||
 | 
			
		||||
          // 从 IndexedDB 删除
 | 
			
		||||
          await speakService.deleteSpeak(id);
 | 
			
		||||
 | 
			
		||||
          // 从状态中移除并释放 URL
 | 
			
		||||
          set(state => {
 | 
			
		||||
            const voiceToDelete = state.voiceList.find(voice => voice.id === id);
 | 
			
		||||
            if (voiceToDelete && voiceToDelete.url && voiceToDelete.url.startsWith('blob:')) {
 | 
			
		||||
              URL.revokeObjectURL(voiceToDelete.url);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
              voiceList: state.voiceList.filter(voice => voice.id !== id)
 | 
			
		||||
            };
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error('删除语音记录失败:', error);
 | 
			
		||||
          setError(error instanceof Error ? error.message : '删除失败');
 | 
			
		||||
          throw error;
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      // 识别语音记录
 | 
			
		||||
      recognizeVoice: async (id: string) => {
 | 
			
		||||
        const { setError } = get();
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
          setError(null);
 | 
			
		||||
 | 
			
		||||
          // 获取语音记录
 | 
			
		||||
          const voice = get().voiceList.find(v => v.id === id);
 | 
			
		||||
          if (!voice || !voice.file) {
 | 
			
		||||
            throw new Error('找不到语音记录或音频数据');
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // 调用语音识别API
 | 
			
		||||
          const result = await getText(voice.file);
 | 
			
		||||
          const recognizedText = result.text;
 | 
			
		||||
 | 
			
		||||
          if (!recognizedText) {
 | 
			
		||||
            throw new Error('语音识别失败,未能获取到文字内容');
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // 更新数据库中的记录
 | 
			
		||||
          await speakService.updateSpeak(id, { text: recognizedText });
 | 
			
		||||
 | 
			
		||||
          // 更新状态中的记录
 | 
			
		||||
          set(state => ({
 | 
			
		||||
            voiceList: state.voiceList.map(voice =>
 | 
			
		||||
              voice.id === id ? { ...voice, text: recognizedText } : voice
 | 
			
		||||
            )
 | 
			
		||||
          }));
 | 
			
		||||
 | 
			
		||||
          return recognizedText;
 | 
			
		||||
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error('语音识别失败:', error);
 | 
			
		||||
          setError(error instanceof Error ? error.message : '语音识别失败');
 | 
			
		||||
          throw error;
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      // 清空今天的语音记录
 | 
			
		||||
      clearTodayVoices: async () => {
 | 
			
		||||
        const { setError, currentDay } = get();
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
          setError(null);
 | 
			
		||||
 | 
			
		||||
          // 从 IndexedDB 清空今天的记录
 | 
			
		||||
          await speakService.deleteSpeaksByDay(currentDay);
 | 
			
		||||
 | 
			
		||||
          // 清空状态并释放所有 URL
 | 
			
		||||
          set(state => {
 | 
			
		||||
            state.voiceList.forEach(voice => {
 | 
			
		||||
              if (voice.url && voice.url.startsWith('blob:')) {
 | 
			
		||||
                URL.revokeObjectURL(voice.url);
 | 
			
		||||
              }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            return { voiceList: [] };
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error('清空今天语音记录失败:', error);
 | 
			
		||||
          setError(error instanceof Error ? error.message : '清空失败');
 | 
			
		||||
          throw error;
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      // 为所有记录生成音频 URL
 | 
			
		||||
      generateAudioUrls: async () => {
 | 
			
		||||
        const { voiceList } = get();
 | 
			
		||||
 | 
			
		||||
        set(state => ({
 | 
			
		||||
          voiceList: state.voiceList.map(voice => {
 | 
			
		||||
            // 如果已经有 URL 且是 blob URL,跳过
 | 
			
		||||
            if (voice.url && voice.url.startsWith('blob:')) {
 | 
			
		||||
              return voice;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 如果有 file 数据,从 base64 生成 URL
 | 
			
		||||
            if (voice.file) {
 | 
			
		||||
              return {
 | 
			
		||||
                ...voice,
 | 
			
		||||
                url: base64ToUrl(voice.file)
 | 
			
		||||
              };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return voice;
 | 
			
		||||
          })
 | 
			
		||||
        }));
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      // 刷新列表(重新从 IndexedDB 获取)
 | 
			
		||||
      refreshList: async () => {
 | 
			
		||||
        const { initialize } = get();
 | 
			
		||||
        await initialize();
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      // 设置错误信息
 | 
			
		||||
      setError: (error: string | null) => {
 | 
			
		||||
        set({ error });
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      // 设置加载状态
 | 
			
		||||
      setLoading: (loading: boolean) => {
 | 
			
		||||
        set({ isLoading: loading });
 | 
			
		||||
      }
 | 
			
		||||
    }),
 | 
			
		||||
    {
 | 
			
		||||
      name: 'voice-store', // persist key
 | 
			
		||||
    }
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// 导出类型以便其他地方使用
 | 
			
		||||
export type { VoiceState };
 | 
			
		||||
 | 
			
		||||
// 辅助 hooks
 | 
			
		||||
export const useVoiceList = () => useVoiceStore(state => state.voiceList);
 | 
			
		||||
export const useVoiceLoading = () => useVoiceStore(state => state.isLoading);
 | 
			
		||||
export const useVoiceError = () => useVoiceStore(state => state.error);
 | 
			
		||||
 | 
			
		||||
// 清理函数:页面卸载时释放所有 blob URLs
 | 
			
		||||
export const cleanupVoiceUrls = () => {
 | 
			
		||||
  const { voiceList } = useVoiceStore.getState();
 | 
			
		||||
  voiceList.forEach(voice => {
 | 
			
		||||
    if (voice.url && voice.url.startsWith('blob:')) {
 | 
			
		||||
      URL.revokeObjectURL(voice.url);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 在页面卸载时自动清理
 | 
			
		||||
if (typeof window !== 'undefined') {
 | 
			
		||||
  window.addEventListener('beforeunload', cleanupVoiceUrls);
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user