feat(mini): add voice recording compatibility logging
This commit is contained in:
@@ -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<string, any> = {}
|
||||
try {
|
||||
systemInfo = Taro.getSystemInfoSync() as Record<string, any>
|
||||
} 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<void>>(async () => {})
|
||||
const recordingChildIdRef = useRef<number | null>(null)
|
||||
const recordProfileRef = useRef<RecordProfile>(DEFAULT_RECORD_PROFILE)
|
||||
const runtimeInfoRef = useRef<VoiceRuntimeInfo>(FALLBACK_RUNTIME_INFO)
|
||||
const recordStartedAtRef = useRef<number>(0)
|
||||
const playingMessageIdRef = useRef<number | null>(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<boolean> => {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, any>
|
||||
}
|
||||
): Promise<ConversationMessageCreateResponse> {
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user