feat(memory,conversation): 记忆富化/证据包、时间线幂等字段与对话分段全链路
数据库 - 新增迁移 0003:timeline_events.memory_source_id 外键 → memory_sources,便于按 ingest 源做时间线幂等 后端 - 记忆 - 新增 ingest 后 LLM 富化(摘要/事实/时间线),可配置开关与最大字符数 - 新增证据包组装:合并 chunk、摘要、事实、时间线、故事等检索结果;支持空 query 时是否仍带 rolling 等开关 - repo/retriever/service/router/schemas/summarizer/timeline/extractor 等扩展;文档 memory-retrieval.md 更新 后端 - 对话 WS - 增加 PING/PONG;分段 ASR 日志与空音频处理;转写失败与「无助手回复」错误提示更明确 - 助手多段回复持久化使用统一分隔符,与分段逻辑一致 后端 - Agent - reply_limits:按 [SPLIT] 与段落拆段,并保证非空 fallback,供 WS 与 TTS 多段下发 后端 - 回忆录任务 - transcript ingest 记录 source_id;任务成功结?
This commit is contained in:
@@ -47,7 +47,13 @@ export function usePlayer(): UsePlayerResult {
|
||||
typeof currentSource === 'string' &&
|
||||
(currentSource.startsWith('https://') ||
|
||||
currentSource.startsWith('http://'));
|
||||
return { downloadFirst: remote };
|
||||
return {
|
||||
downloadFirst: remote,
|
||||
// Expo's native player deactivates AVAudioSession on pause by default.
|
||||
// We manage session ownership centrally via audioFocus, so keep it active
|
||||
// until audioFocus.release() explicitly tears it down.
|
||||
keepAudioSessionActive: true,
|
||||
};
|
||||
}, [currentSource]);
|
||||
|
||||
const player = useAudioPlayer(currentSource, playerOptions);
|
||||
@@ -76,7 +82,7 @@ export function usePlayer(): UsePlayerResult {
|
||||
setCurrentSource(null);
|
||||
setStatus('idle');
|
||||
setQueueLength(0);
|
||||
await audioFocus.release();
|
||||
await audioFocus.releaseIfOwnedBy('player');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,7 +176,7 @@ export function usePlayer(): UsePlayerResult {
|
||||
setCurrentPlaybackItem(null);
|
||||
setCurrentSource(null);
|
||||
setStatus('idle');
|
||||
await audioFocus.release();
|
||||
await audioFocus.releaseIfOwnedBy('player');
|
||||
await playNext();
|
||||
},
|
||||
[player, playNext],
|
||||
@@ -189,7 +195,7 @@ export function usePlayer(): UsePlayerResult {
|
||||
setCurrentPlaybackItem(null);
|
||||
setCurrentSource(null);
|
||||
setStatus('idle');
|
||||
await audioFocus.release();
|
||||
await audioFocus.releaseIfOwnedBy('player');
|
||||
}, [player]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { VoiceRecorder } from '../recorder';
|
||||
import type { RecorderStatus } from '../types';
|
||||
import type { RecorderStartResult, RecorderStatus } from '../types';
|
||||
|
||||
interface UseRecorderResult {
|
||||
status: RecorderStatus;
|
||||
durationMs: number;
|
||||
start: () => Promise<boolean>;
|
||||
start: () => Promise<RecorderStartResult>;
|
||||
stop: () => Promise<{ uri: string; durationMs: number } | null>;
|
||||
cancel: () => Promise<void>;
|
||||
}
|
||||
@@ -44,16 +44,21 @@ export function useRecorder(
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const recorder = recorderRef.current;
|
||||
if (!recorder) return false;
|
||||
if (!recorder) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'recorder_unavailable',
|
||||
} as const;
|
||||
}
|
||||
|
||||
const ok = await recorder.start();
|
||||
if (ok) {
|
||||
const result = await recorder.start();
|
||||
if (result.ok) {
|
||||
setDurationMs(0);
|
||||
durationTimer.current = setInterval(() => {
|
||||
setDurationMs(recorder.getDurationMs());
|
||||
}, 200);
|
||||
}
|
||||
return ok;
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Platform } from 'react-native';
|
||||
|
||||
import {
|
||||
AudioModule,
|
||||
AudioQuality,
|
||||
IOSOutputFormat,
|
||||
type RecordingOptions,
|
||||
RecordingPresets,
|
||||
} from 'expo-audio';
|
||||
import { audioFocus } from '@/core/audio/audio-focus';
|
||||
|
||||
import type { RecorderStatus } from './types';
|
||||
import type { RecorderStartResult, RecorderStatus } from './types';
|
||||
|
||||
// Native module exposes AudioRecorder as constructor; ESLint import/namespace doesn't resolve it
|
||||
// eslint-disable-next-line import/namespace -- AudioModule.AudioRecorder exists at runtime
|
||||
@@ -16,6 +17,30 @@ const AudioRecorderCtor = AudioModule.AudioRecorder;
|
||||
type StatusListener = (status: RecorderStatus) => void;
|
||||
type RecordingCompleteListener = (uri: string, durationMs: number) => void;
|
||||
|
||||
/**
|
||||
* Tencent SentenceRecognition is currently called with `EngSerViceType=16k_zh`
|
||||
* and `VoiceFormat=m4a`, so record speech in that shape directly instead of
|
||||
* relying on Expo's default 44.1 kHz stereo preset.
|
||||
*/
|
||||
export const VOICE_RECORDING_OPTIONS: RecordingOptions = {
|
||||
extension: '.m4a',
|
||||
sampleRate: 16_000,
|
||||
numberOfChannels: 1,
|
||||
bitRate: 32_000,
|
||||
android: {
|
||||
outputFormat: 'mpeg4',
|
||||
audioEncoder: 'aac',
|
||||
},
|
||||
ios: {
|
||||
outputFormat: IOSOutputFormat.MPEG4AAC,
|
||||
audioQuality: AudioQuality.HIGH,
|
||||
},
|
||||
web: {
|
||||
mimeType: 'audio/webm',
|
||||
bitsPerSecond: 32_000,
|
||||
},
|
||||
};
|
||||
|
||||
/** Platform-specific recording options (expo-audio internal createRecordingOptions logic). */
|
||||
function createRecordingOptions(
|
||||
options: RecordingOptions,
|
||||
@@ -32,6 +57,15 @@ function createRecordingOptions(
|
||||
return { ...common, ...options.web };
|
||||
}
|
||||
|
||||
export function buildVoiceRecordingOptions(): Partial<RecordingOptions> {
|
||||
return createRecordingOptions(VOICE_RECORDING_OPTIONS);
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Class-level wrapper over expo-audio recording.
|
||||
* No React dependency — hooks/ layer adapts this to React.
|
||||
@@ -55,29 +89,35 @@ export class VoiceRecorder {
|
||||
return result.granted;
|
||||
}
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
if (this.status !== 'idle') return false;
|
||||
async start(): Promise<RecorderStartResult> {
|
||||
if (this.status !== 'idle') {
|
||||
return { ok: false, reason: 'prepare_failed' };
|
||||
}
|
||||
|
||||
const hasPermission = await this.requestPermission();
|
||||
if (!hasPermission) return false;
|
||||
if (!hasPermission) {
|
||||
return { ok: false, reason: 'permission_denied' };
|
||||
}
|
||||
|
||||
const acquired = await audioFocus.acquireForRecording();
|
||||
if (!acquired) return false;
|
||||
if (!acquired) {
|
||||
return { ok: false, reason: 'audio_focus_unavailable' };
|
||||
}
|
||||
|
||||
this.setStatus('preparing');
|
||||
|
||||
try {
|
||||
this.recorder = new AudioRecorderCtor(
|
||||
createRecordingOptions(RecordingPresets.HIGH_QUALITY),
|
||||
);
|
||||
this.recorder = new AudioRecorderCtor(buildVoiceRecordingOptions());
|
||||
await this.recorder.prepareToRecordAsync();
|
||||
this.recorder.record();
|
||||
this.startTime = Date.now();
|
||||
this.setStatus('recording');
|
||||
return true;
|
||||
} catch {
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
console.warn('VoiceRecorder.start failed during prepare', errorMessage);
|
||||
await this.cleanup();
|
||||
return false;
|
||||
return { ok: false, reason: 'prepare_failed', errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +191,6 @@ export class VoiceRecorder {
|
||||
this.recorder = null;
|
||||
this.startTime = 0;
|
||||
this.setStatus('idle');
|
||||
await audioFocus.release();
|
||||
await audioFocus.releaseIfOwnedBy('recorder');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
export type RecorderStatus = 'idle' | 'preparing' | 'recording' | 'stopping';
|
||||
|
||||
export type RecorderStartFailureReason =
|
||||
| 'permission_denied'
|
||||
| 'audio_focus_unavailable'
|
||||
| 'prepare_failed'
|
||||
| 'recorder_unavailable';
|
||||
|
||||
export interface RecorderStartResult {
|
||||
ok: boolean;
|
||||
reason?: RecorderStartFailureReason;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// ─── Segmenter ───
|
||||
|
||||
export type SegmentStrategy = 'fixed-duration';
|
||||
|
||||
Reference in New Issue
Block a user