feat(mini): add voice recording compatibility logging

This commit is contained in:
stu2not
2026-05-25 17:26:57 +08:00
parent 2639ab06ab
commit 5d86ee5826
3 changed files with 345 additions and 16 deletions

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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,