From 5d86ee5826af66bc931d916d0c4cb6c2a1105705 Mon Sep 17 00:00:00 2001 From: stu2not Date: Mon, 25 May 2026 17:26:57 +0800 Subject: [PATCH] feat(mini): add voice recording compatibility logging --- banban-mini/src/pages/chat/detail/index.tsx | 291 +++++++++++++++++++- banban-mini/src/services/chat.ts | 44 ++- talkingq-url/banban/routers/im.py | 26 +- 3 files changed, 345 insertions(+), 16 deletions(-) diff --git a/banban-mini/src/pages/chat/detail/index.tsx b/banban-mini/src/pages/chat/detail/index.tsx index 0346222..3b0866b 100644 --- a/banban-mini/src/pages/chat/detail/index.tsx +++ b/banban-mini/src/pages/chat/detail/index.tsx @@ -53,6 +53,135 @@ function formatAudioDuration(durationMs?: number | null): string { return `${totalSeconds}s` } +type VoiceRuntimeInfo = { + platform: string + system: string + brand: string + model: string + version: string + SDKVersion: string + isIOS: boolean + isAndroid: boolean + isHarmony: boolean + canUseRecorderManager: boolean + canUseInnerAudioContext: boolean + supportsMp3Record: boolean +} + +type RecordProfile = { + format: 'mp3' | 'aac' + mimeType: string + sampleRate: number + encodeBitRate: number +} + +const DEFAULT_RECORD_PROFILE: RecordProfile = { + format: 'mp3', + mimeType: 'audio/mpeg', + sampleRate: 16000, + encodeBitRate: 96000, +} + +const COMPAT_RECORD_PROFILE: RecordProfile = { + format: 'aac', + mimeType: 'audio/aac', + sampleRate: 16000, + encodeBitRate: 64000, +} + +const FALLBACK_RUNTIME_INFO: VoiceRuntimeInfo = { + platform: '', + system: '', + brand: '', + model: '', + version: '', + SDKVersion: '', + isIOS: false, + isAndroid: false, + isHarmony: false, + canUseRecorderManager: false, + canUseInnerAudioContext: false, + supportsMp3Record: true, +} + +function compareVersion(left: string, right: string): number { + const leftParts = left.split('.').map((item) => Number(item) || 0) + const rightParts = right.split('.').map((item) => Number(item) || 0) + const length = Math.max(leftParts.length, rightParts.length) + for (let index = 0; index < length; index += 1) { + const diff = (leftParts[index] || 0) - (rightParts[index] || 0) + if (diff !== 0) return diff + } + return 0 +} + +function getCanIUse(apiName: string): boolean { + try { + if (typeof Taro.canIUse !== 'function') return false + return Boolean(Taro.canIUse(apiName)) + } catch { + return false + } +} + +function readVoiceRuntimeInfo(): VoiceRuntimeInfo { + let systemInfo: Record = {} + try { + systemInfo = Taro.getSystemInfoSync() as Record + } catch (error) { + console.warn('[voice-runtime] getSystemInfoSync failed', error) + } + + const platform = String(systemInfo.platform || '').toLowerCase() + const system = String(systemInfo.system || '') + const brand = String(systemInfo.brand || '') + const model = String(systemInfo.model || '') + const version = String(systemInfo.version || '') + const SDKVersion = String(systemInfo.SDKVersion || '') + const systemText = `${system} ${brand} ${model}`.toLowerCase() + const isHarmony = systemText.includes('harmony') || systemText.includes('openharmony') || systemText.includes('鸿蒙') + const isIOS = platform === 'ios' || systemText.includes('ios') + const isAndroid = platform === 'android' || systemText.includes('android') || isHarmony + + return { + platform, + system, + brand, + model, + version, + SDKVersion, + isIOS, + isAndroid, + isHarmony, + canUseRecorderManager: typeof Taro.getRecorderManager === 'function', + canUseInnerAudioContext: typeof Taro.createInnerAudioContext === 'function', + supportsMp3Record: !SDKVersion || compareVersion(SDKVersion, '2.6.0') >= 0, + } +} + +function getPreferredRecordProfile(runtime: VoiceRuntimeInfo): RecordProfile { + if (runtime.isHarmony || !runtime.supportsMp3Record) return COMPAT_RECORD_PROFILE + return DEFAULT_RECORD_PROFILE +} + +function getAudioUrlDebugInfo(rawUrl: string) { + const protocolMatch = rawUrl.match(/^([a-z]+):\/\//i) + let host = '' + try { + const match = rawUrl.match(/^[a-z]+:\/\/([^/?#]+)/i) + host = match?.[1] || '' + } catch { + host = '' + } + return { + rawUrl, + protocol: protocolMatch?.[1]?.toLowerCase() || '', + host, + isHttp: rawUrl.startsWith('http://'), + isHttps: rawUrl.startsWith('https://'), + } +} + export default function ChatDetail() { const systemBanner = useSystemBanner() const router = useRouter() @@ -99,21 +228,61 @@ export default function ChatDetail() { const activeConversationIdRef = useRef(initialConversationId) const latestLoadChatDataRef = useRef<(conversationId: number) => Promise>(async () => {}) const recordingChildIdRef = useRef(null) + const recordProfileRef = useRef(DEFAULT_RECORD_PROFILE) + const runtimeInfoRef = useRef(FALLBACK_RUNTIME_INFO) + const recordStartedAtRef = useRef(0) + const playingMessageIdRef = useRef(null) useEffect(() => { + runtimeInfoRef.current = readVoiceRuntimeInfo() + recordProfileRef.current = getPreferredRecordProfile(runtimeInfoRef.current) + console.info('[voice-runtime] detected', { + ...runtimeInfoRef.current, + preferredRecordProfile: recordProfileRef.current, + canIUse: { + getRecorderManager: getCanIUse('getRecorderManager'), + createInnerAudioContext: getCanIUse('createInnerAudioContext'), + uploadFile: getCanIUse('uploadFile'), + }, + }) + }, []) + + useEffect(() => { + if (typeof Taro.createInnerAudioContext !== 'function') { + console.warn('[audio] InnerAudioContext unavailable', runtimeInfoRef.current) + return + } + const audioContext = Taro.createInnerAudioContext() audioContextRef.current = audioContext audioContext.onEnded(() => { + console.info('[audio] ended', { playingMessageId: playingMessageIdRef.current }) setPlayingMessageId(null) }) audioContext.onStop(() => { + console.info('[audio] stopped', { playingMessageId: playingMessageIdRef.current }) setPlayingMessageId(null) }) - audioContext.onError(() => { + audioContext.onError((error) => { setPlayingMessageId(null) + console.error('[audio] error', { + playingMessageId: playingMessageIdRef.current, + src: audioContext.src, + error, + runtime: runtimeInfoRef.current, + }) Taro.showToast({ title: '语音播放失败', icon: 'none' }) }) + audioContext.onPlay(() => { + console.info('[audio] play event', { playingMessageId: playingMessageIdRef.current, src: audioContext.src }) + }) + audioContext.onWaiting(() => { + console.info('[audio] waiting', { playingMessageId: playingMessageIdRef.current, src: audioContext.src }) + }) + audioContext.onCanplay(() => { + console.info('[audio] canplay', { playingMessageId: playingMessageIdRef.current, src: audioContext.src }) + }) return () => { audioContext.destroy() @@ -125,6 +294,10 @@ export default function ChatDetail() { activeConversationIdRef.current = activeConversationId }, [activeConversationId]) + useEffect(() => { + playingMessageIdRef.current = playingMessageId + }, [playingMessageId]) + useEffect(() => { void loadChatData(activeConversationId) }, [activeConversationId, conversationSource, conversationPeerKind]) @@ -166,42 +339,85 @@ export default function ChatDetail() { latestLoadChatDataRef.current = loadChatData useEffect(() => { - if (typeof Taro.getRecorderManager !== 'function') return + if (typeof Taro.getRecorderManager !== 'function') { + console.warn('[record] RecorderManager unavailable', runtimeInfoRef.current) + return + } const recorderManager = Taro.getRecorderManager() recorderManagerRef.current = recorderManager + recorderManager.onStart(() => { + console.info('[record] started', { + profile: recordProfileRef.current, + runtime: runtimeInfoRef.current, + }) + }) + recorderManager.onError((error) => { recordingChildIdRef.current = null + recordStartedAtRef.current = 0 setRecording(false) setRecordHint('长按开始留言') - console.error('[chat-detail] recorder failed:', error) + console.error('[record] error', { + error, + profile: recordProfileRef.current, + runtime: runtimeInfoRef.current, + }) Taro.showToast({ title: '录音失败,请重试', icon: 'none' }) }) recorderManager.onStop(async (result) => { const currentChildId = recordingChildIdRef.current + const startedAt = recordStartedAtRef.current + const fallbackDurationMs = startedAt > 0 ? Date.now() - startedAt : 0 recordingChildIdRef.current = null + recordStartedAtRef.current = 0 setRecording(false) setRecordHint('长按开始留言') + console.info('[record] stopped', { + childId: currentChildId, + tempFilePath: result.tempFilePath, + duration: result.duration, + fileSize: result.fileSize, + fallbackDurationMs, + profile: recordProfileRef.current, + runtime: runtimeInfoRef.current, + }) + if (!currentChildId) return if (!result.tempFilePath) { + console.warn('[record] missing tempFilePath', { result }) Taro.showToast({ title: '录音文件不存在', icon: 'none' }) return } - const durationMs = Math.max(0, Number(result.duration || 0)) + const durationMs = Math.max(0, Number(result.duration || 0) || fallbackDurationMs) if (durationMs < 800) { + console.warn('[record] too short', { + durationMs, + resultDuration: result.duration, + fallbackDurationMs, + }) Taro.showToast({ title: '留言太短,请重试', icon: 'none' }) return } setSending(true) try { + const profile = recordProfileRef.current const response = await sendParentVoiceMessage(currentChildId, { filePath: result.tempFilePath, durationMs, + audioFormat: profile.format, + mimeType: profile.mimeType, + runtime: runtimeInfoRef.current, + }) + console.info('[record] uploaded', { + childId: currentChildId, + conversationId: response.conversation_id, + messageId: response.message?.id, }) if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) { setActiveConversationId(response.conversation_id) @@ -232,16 +448,27 @@ export default function ChatDetail() { const ensureRecordPermission = async (): Promise => { try { const settings = await Taro.getSetting() - if (settings.authSetting['scope.record']) return true + const granted = Boolean(settings.authSetting['scope.record']) + console.info('[record] permission status', { + granted, + runtime: runtimeInfoRef.current, + }) + if (granted) return true await Taro.authorize({ scope: 'scope.record' }) + console.info('[record] permission granted by authorize') return true - } catch { + } catch (error) { + console.warn('[record] permission denied or authorize failed', { + error, + runtime: runtimeInfoRef.current, + }) const modal = await Taro.showModal({ title: '需要录音权限', content: '请允许小程序使用麦克风后再留言', confirmText: '去设置', }) if (modal.confirm) { + console.info('[record] openSetting requested') await Taro.openSetting() } return false @@ -264,22 +491,37 @@ export default function ChatDetail() { const hasPermission = await ensureRecordPermission() if (!hasPermission) return + const runtime = readVoiceRuntimeInfo() + const profile = getPreferredRecordProfile(runtime) + runtimeInfoRef.current = runtime + recordProfileRef.current = profile recordingChildIdRef.current = character.childId + recordStartedAtRef.current = Date.now() setRecording(true) setRecordHint('松开发送留言') + console.info('[record] start request', { + childId: character.childId, + profile, + runtime, + }) try { recorderManager.start({ duration: 60000, - format: 'mp3', + format: profile.format, numberOfChannels: 1, - sampleRate: 16000, - encodeBitRate: 96000, + sampleRate: profile.sampleRate, + encodeBitRate: profile.encodeBitRate, }) } catch (error: any) { recordingChildIdRef.current = null + recordStartedAtRef.current = 0 setRecording(false) setRecordHint('长按开始留言') - console.error('[chat-detail] recorder start failed:', error) + console.error('[record] start failed:', { + error, + profile, + runtime, + }) Taro.showToast({ title: '无法开始录音,请重试', icon: 'none', @@ -290,9 +532,15 @@ export default function ChatDetail() { const handleRecordStop = () => { if (!recording) return const recorderManager = recorderManagerRef.current - if (!recorderManager) return + if (!recorderManager) { + console.warn('[record] stop skipped: recorder unavailable') + return + } setRecordHint('正在处理留言...') + console.info('[record] stop request', { + elapsedMs: recordStartedAtRef.current > 0 ? Date.now() - recordStartedAtRef.current : 0, + }) recorderManager.stop() } @@ -302,13 +550,29 @@ export default function ChatDetail() { return } + const urlInfo = getAudioUrlDebugInfo(message.mediaUrl) + console.info('[audio] play request', { + messageId: message.id, + conversationId: message.conversationId, + contentType: message.contentType, + mediaDurationMs: message.mediaDurationMs, + mediaMimeType: message.mediaMimeType, + url: urlInfo, + runtime: runtimeInfoRef.current, + }) + if (urlInfo.isHttp) { + console.warn('[audio] http url may fail in experience/release builds', urlInfo) + } + const audioContext = audioContextRef.current if (!audioContext) { + console.warn('[audio] play skipped: audio context unavailable', runtimeInfoRef.current) Taro.showToast({ title: '播放器未就绪', icon: 'none' }) return } if (playingMessageId === message.id) { + console.info('[audio] stop current', { messageId: message.id }) audioContext.stop() setPlayingMessageId(null) return @@ -318,6 +582,11 @@ export default function ChatDetail() { audioContext.src = message.mediaUrl audioContext.autoplay = true audioContext.play() + console.info('[audio] play invoked', { + messageId: message.id, + srcRaw: message.mediaUrl, + src: audioContext.src, + }) setPlayingMessageId(message.id) } diff --git a/banban-mini/src/services/chat.ts b/banban-mini/src/services/chat.ts index 65ad358..2f02ea4 100644 --- a/banban-mini/src/services/chat.ts +++ b/banban-mini/src/services/chat.ts @@ -690,7 +690,14 @@ function getUploadErrorMessage(data: any): string { export async function sendParentVoiceMessage( childId: number, - options: { filePath: string; durationMs: number; transcriptText?: string } + options: { + filePath: string + durationMs: number + transcriptText?: string + audioFormat?: string + mimeType?: string + runtime?: Record + } ): Promise { if (!childId) { throw new Error('当前会话不支持留言') @@ -701,6 +708,16 @@ export async function sendParentVoiceMessage( const token = getToken() const requestUrl = `${BASE_URL}/banban/children/${childId}/voice-message` + console.info('[voice-upload] request', { + childId, + requestUrl, + filePath: options.filePath, + durationMs: options.durationMs, + audioFormat: options.audioFormat || '', + mimeType: options.mimeType || '', + runtime: options.runtime || {}, + hasToken: Boolean(token), + }) const uploadResponse: any = await new Promise((resolve, reject) => { Taro.uploadFile({ url: requestUrl, @@ -715,9 +732,26 @@ export async function sendParentVoiceMessage( duration_ms: `${Math.max(1, Math.round(options.durationMs || 0))}`, client_msg_id: createClientMessageId(), transcript_text: options.transcriptText || '', + audio_format: options.audioFormat || '', + mime_type: options.mimeType || '', + }, + success: (response) => { + console.info('[voice-upload] response', { + statusCode: response.statusCode, + errMsg: response.errMsg, + dataType: typeof response.data, + }) + resolve(response) + }, + fail: (error) => { + console.error('[voice-upload] fail', { + childId, + requestUrl, + errMsg: error?.errMsg, + message: error?.message, + }) + reject(error) }, - success: resolve, - fail: reject, }) }).catch((error: any) => { const message = String(error?.errMsg || error?.message || 'request:fail') @@ -730,6 +764,10 @@ export async function sendParentVoiceMessage( const data = parseUploadResponseData(uploadResponse.data) if (uploadResponse.statusCode >= 400) { + console.error('[voice-upload] error response', { + statusCode: uploadResponse.statusCode, + data, + }) throw new ApiError(uploadResponse.statusCode, getUploadErrorMessage(data)) } diff --git a/talkingq-url/banban/routers/im.py b/talkingq-url/banban/routers/im.py index c89d2ba..4c8abff 100644 --- a/talkingq-url/banban/routers/im.py +++ b/talkingq-url/banban/routers/im.py @@ -455,15 +455,37 @@ async def create_child_voice_message_for_parent( duration_ms: int = Form(..., ge=0), client_msg_id: str = Form(..., min_length=1, max_length=64), transcript_text: str | None = Form(default=None, max_length=1000), + audio_format: str | None = Form(default=None, max_length=16), + mime_type: str | None = Form(default=None, max_length=64), current_user_id: int = Depends(get_current_user_id), ) -> ConversationMessageCreateResponse: try: content = await file.read() + normalized_format = (audio_format or "").strip().lower().lstrip(".") + filename = file.filename or (f"voice.{normalized_format}" if normalized_format else None) + content_type = (mime_type or "").strip() or file.content_type + logger.info( + "parent child voice upload received", + extra={ + "event": "parent_child_voice_upload_receive", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "child_id": child_id, + "file_name": file.filename, + "effective_file_name": filename, + "file_content_type": file.content_type, + "client_mime_type": mime_type, + "effective_content_type": content_type, + "client_audio_format": audio_format, + "duration_ms": duration_ms, + "size_bytes": len(content), + }, + ) result = await im_service.create_parent_child_voice_message( parent_user_id=current_user_id, child_id=child_id, - filename=file.filename, - content_type=file.content_type, + filename=filename, + content_type=content_type, content=content, media_duration_ms=duration_ms, media_transcript_text=transcript_text,