Squash merge feat/expo-app: app-expo, .cursor, workflows, package.json, .husky; remove app-android, app-ios, react-app

This commit is contained in:
Kevin
2026-03-19 01:12:17 +08:00
parent 9e4f301ab9
commit b4f4369b7d
544 changed files with 23707 additions and 67151 deletions

View File

@@ -0,0 +1,131 @@
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
import { useCallback, useEffect, useRef, useState } from 'react';
import { audioFocus } from '@/core/audio/audio-focus';
import type { PlaybackItem, PlayerStatus } from '../types';
interface UsePlayerResult {
status: PlayerStatus;
queueLength: number;
enqueueTtsAudio: (audioBase64: string) => void;
enqueue: (item: PlaybackItem) => void;
stop: () => void;
}
/**
* Self-contained audio playback hook with queue management.
*
* - Uses useAudioPlayer + useAudioPlayerStatus for native playback
* - Detects playback completion to auto-advance the queue
* - Coordinates with audioFocus for recording/playback mutual exclusion
* - Subscribes to audioFocus owner changes to resume after recording ends
*/
export function usePlayer(): UsePlayerResult {
const queueRef = useRef<PlaybackItem[]>([]);
const [status, setStatus] = useState<PlayerStatus>('idle');
const [queueLength, setQueueLength] = useState(0);
const [currentSource, setCurrentSource] = useState<string | null>(null);
const isPlayingRef = useRef(false);
const wasBlockedByRecorderRef = useRef(false);
const player = useAudioPlayer(currentSource);
const playerStatus = useAudioPlayerStatus(player);
// Start playback when a new source is set
useEffect(() => {
if (currentSource && player) {
player.play();
isPlayingRef.current = true;
}
}, [currentSource, player]);
const playNext = useCallback(async () => {
if (queueRef.current.length === 0) {
setCurrentSource(null);
setStatus('idle');
setQueueLength(0);
await audioFocus.release();
return;
}
const acquired = await audioFocus.acquireForPlayback();
if (!acquired) {
setStatus('idle');
return;
}
const next = queueRef.current.shift()!;
setQueueLength(queueRef.current.length);
setStatus('playing');
setCurrentSource(next.uri);
}, []);
// Detect playback completion → advance queue
useEffect(() => {
if (!currentSource || !isPlayingRef.current) return;
const { playing, currentTime, duration } = playerStatus;
const finished = !playing && duration > 0 && currentTime >= duration - 0.05;
if (finished) {
isPlayingRef.current = false;
playNext();
}
}, [playerStatus, currentSource, playNext]);
// Subscribe to audioFocus owner changes for recorder → idle recovery
useEffect(() => {
const unsub = audioFocus.onOwnerChange((owner) => {
if (owner === 'recorder') {
wasBlockedByRecorderRef.current =
queueRef.current.length > 0 || currentSource !== null;
}
if (owner === null && wasBlockedByRecorderRef.current) {
wasBlockedByRecorderRef.current = false;
if (queueRef.current.length > 0 && status === 'idle') {
playNext();
}
}
});
return unsub;
}, [status, currentSource, playNext]);
const enqueue = useCallback(
async (item: PlaybackItem) => {
queueRef.current.push(item);
setQueueLength(queueRef.current.length);
if (status === 'idle' && !currentSource) {
await playNext();
}
},
[status, currentSource, playNext],
);
const enqueueTtsAudio = useCallback(
(audioBase64: string) => {
const uri = `data:audio/mp3;base64,${audioBase64}`;
enqueue({ uri, label: 'TTS' });
},
[enqueue],
);
const stop = useCallback(async () => {
queueRef.current = [];
setQueueLength(0);
isPlayingRef.current = false;
if (player) {
player.pause();
}
setCurrentSource(null);
setStatus('idle');
await audioFocus.release();
}, [player]);
return { status, queueLength, enqueueTtsAudio, enqueue, stop };
}

View File

@@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { VoiceRecorder } from '../recorder';
import type { RecorderStatus } from '../types';
interface UseRecorderResult {
status: RecorderStatus;
durationMs: number;
start: () => Promise<boolean>;
stop: () => Promise<{ uri: string; durationMs: number } | null>;
cancel: () => Promise<void>;
}
export function useRecorder(
onRecordingComplete?: (uri: string, durationMs: number) => void,
): UseRecorderResult {
const recorderRef = useRef<VoiceRecorder | null>(null);
const onCompleteRef = useRef(onRecordingComplete);
onCompleteRef.current = onRecordingComplete;
const [status, setStatus] = useState<RecorderStatus>('idle');
const [durationMs, setDurationMs] = useState(0);
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const stableOnComplete = useCallback((uri: string, durationMs: number) => {
onCompleteRef.current?.(uri, durationMs);
}, []);
useEffect(() => {
const recorder = new VoiceRecorder();
recorderRef.current = recorder;
const unsubStatus = recorder.onStatusChange(setStatus);
const unsubComplete = recorder.onRecordingComplete(stableOnComplete);
return () => {
unsubStatus();
unsubComplete();
recorder.cancel();
recorderRef.current = null;
if (durationTimer.current) clearInterval(durationTimer.current);
};
}, [stableOnComplete]);
const start = useCallback(async () => {
const recorder = recorderRef.current;
if (!recorder) return false;
const ok = await recorder.start();
if (ok) {
setDurationMs(0);
durationTimer.current = setInterval(() => {
setDurationMs(recorder.getDurationMs());
}, 200);
}
return ok;
}, []);
const stop = useCallback(async () => {
if (durationTimer.current) {
clearInterval(durationTimer.current);
durationTimer.current = null;
}
setDurationMs(0);
const recorder = recorderRef.current;
if (!recorder) return null;
setStatus('stopping');
const result = (await recorder.stop()) ?? null;
return result;
}, []);
const cancel = useCallback(async () => {
if (durationTimer.current) {
clearInterval(durationTimer.current);
durationTimer.current = null;
}
await recorderRef.current?.cancel();
setDurationMs(0);
}, []);
return { status, durationMs, start, stop, cancel };
}

View File

@@ -0,0 +1,9 @@
/**
* Player is now fully implemented in hooks/use-player.ts as a self-contained
* React hook. expo-audio's hook-centric API (useAudioPlayer + useAudioPlayerStatus)
* makes a class-level player impractical — completion detection, source replacement,
* and lifecycle management all need React context.
*
* This file re-exports the hook for backward compatibility with any imports.
*/
export { usePlayer } from './hooks/use-player';

View File

@@ -0,0 +1,156 @@
import { Platform } from 'react-native';
import {
AudioModule,
type RecordingOptions,
RecordingPresets,
} from 'expo-audio';
import { audioFocus } from '@/core/audio/audio-focus';
import type { 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
const AudioRecorderCtor = AudioModule.AudioRecorder;
type StatusListener = (status: RecorderStatus) => void;
type RecordingCompleteListener = (uri: string, durationMs: number) => void;
/** Platform-specific recording options (expo-audio internal createRecordingOptions logic). */
function createRecordingOptions(
options: RecordingOptions,
): Partial<RecordingOptions> {
const common = {
extension: options.extension,
sampleRate: options.sampleRate,
numberOfChannels: options.numberOfChannels,
bitRate: options.bitRate,
isMeteringEnabled: options.isMeteringEnabled ?? false,
};
if (Platform.OS === 'ios') return { ...common, ...options.ios };
if (Platform.OS === 'android') return { ...common, ...options.android };
return { ...common, ...options.web };
}
/**
* Class-level wrapper over expo-audio recording.
* No React dependency — hooks/ layer adapts this to React.
*/
interface RecorderInstance {
prepareToRecordAsync(options?: Partial<RecordingOptions>): Promise<void>;
record(): void;
stop(): Promise<void>;
uri: string | null;
}
export class VoiceRecorder {
private recorder: RecorderInstance | null = null;
private status: RecorderStatus = 'idle';
private startTime = 0;
private statusListeners = new Set<StatusListener>();
private completeListeners = new Set<RecordingCompleteListener>();
async requestPermission(): Promise<boolean> {
const result = await AudioModule.requestRecordingPermissionsAsync();
return result.granted;
}
async start(): Promise<boolean> {
if (this.status !== 'idle') return false;
const hasPermission = await this.requestPermission();
if (!hasPermission) return false;
const acquired = await audioFocus.acquireForRecording();
if (!acquired) return false;
this.setStatus('preparing');
try {
this.recorder = new AudioRecorderCtor(
createRecordingOptions(RecordingPresets.HIGH_QUALITY),
);
await this.recorder.prepareToRecordAsync();
this.recorder.record();
this.startTime = Date.now();
this.setStatus('recording');
return true;
} catch {
await this.cleanup();
return false;
}
}
async stop(): Promise<{ uri: string; durationMs: number } | null> {
if (!this.recorder || this.status !== 'recording') {
return null;
}
this.setStatus('stopping');
try {
await this.recorder.stop();
const uri = this.recorder.uri;
const durationMs = Date.now() - this.startTime;
if (uri) {
for (const listener of this.completeListeners) {
listener(uri, durationMs);
}
}
await this.cleanup();
return uri ? { uri, durationMs } : null;
} catch {
await this.cleanup();
return null;
}
}
async cancel(): Promise<void> {
if (this.recorder) {
try {
await this.recorder.stop();
} catch {
// Already stopped or failed
}
}
await this.cleanup();
}
getStatus(): RecorderStatus {
return this.status;
}
getDurationMs(): number {
if (this.status === 'recording' || this.status === 'paused') {
return Date.now() - this.startTime;
}
return 0;
}
onStatusChange(listener: StatusListener): () => void {
this.statusListeners.add(listener);
return () => this.statusListeners.delete(listener);
}
onRecordingComplete(listener: RecordingCompleteListener): () => void {
this.completeListeners.add(listener);
return () => this.completeListeners.delete(listener);
}
private setStatus(next: RecorderStatus): void {
if (this.status === next) return;
this.status = next;
for (const listener of this.statusListeners) {
listener(next);
}
}
private async cleanup(): Promise<void> {
this.recorder = null;
this.startTime = 0;
this.setStatus('idle');
await audioFocus.release();
}
}

View File

@@ -0,0 +1,133 @@
import { executeSql, querySql } from '@/core/storage/sqlite';
import type { SegmentOutboxEntry, SegmentOutboxStatus } from './types';
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS segment_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
voice_session_id TEXT NOT NULL,
segment_index INTEGER NOT NULL,
file_uri TEXT NOT NULL,
duration_ms INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
retry_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
`;
let initialized = false;
async function ensureTable(): Promise<void> {
if (initialized) return;
await executeSql(CREATE_TABLE_SQL);
initialized = true;
}
function mapRow(row: Record<string, unknown>): SegmentOutboxEntry {
return {
id: row.id as number,
conversationId: row.conversation_id as string,
voiceSessionId: row.voice_session_id as string,
segmentIndex: row.segment_index as number,
fileUri: row.file_uri as string,
durationMs: row.duration_ms as number,
status: row.status as SegmentOutboxStatus,
retryCount: row.retry_count as number,
createdAt: row.created_at as number,
};
}
/**
* SQLite-backed outbox for voice segments.
* Stores metadata + queue state only — audio files live on the local filesystem.
*
* State machine: pending → sending → sent | failed
* Single writer pattern enforced by serializing all writes through this module.
*/
export const segmentOutbox = {
async enqueue(
entry: Omit<
SegmentOutboxEntry,
'id' | 'status' | 'retryCount' | 'createdAt'
>,
): Promise<number> {
await ensureTable();
const result = await executeSql(
`INSERT INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`,
[
entry.conversationId,
entry.voiceSessionId,
entry.segmentIndex,
entry.fileUri,
entry.durationMs,
Date.now(),
],
);
return result.lastInsertRowId;
},
async getPending(conversationId?: string): Promise<SegmentOutboxEntry[]> {
await ensureTable();
const sql = conversationId
? `SELECT * FROM segment_outbox WHERE status = 'pending' AND conversation_id = ? ORDER BY created_at ASC`
: `SELECT * FROM segment_outbox WHERE status = 'pending' ORDER BY created_at ASC`;
const params = conversationId ? [conversationId] : [];
const rows = await querySql<Record<string, unknown>>(sql, params);
return rows.map(mapRow);
},
async markSending(id: number): Promise<void> {
await ensureTable();
await executeSql(
`UPDATE segment_outbox SET status = 'sending' WHERE id = ?`,
[id],
);
},
async markSent(id: number): Promise<void> {
await ensureTable();
await executeSql(`UPDATE segment_outbox SET status = 'sent' WHERE id = ?`, [
id,
]);
},
async markFailed(id: number): Promise<void> {
await ensureTable();
await executeSql(
`UPDATE segment_outbox SET status = 'failed', retry_count = retry_count + 1 WHERE id = ?`,
[id],
);
},
async resetFailed(conversationId?: string): Promise<void> {
await ensureTable();
const sql = conversationId
? `UPDATE segment_outbox SET status = 'pending' WHERE status = 'failed' AND conversation_id = ?`
: `UPDATE segment_outbox SET status = 'pending' WHERE status = 'failed'`;
const params = conversationId ? [conversationId] : [];
await executeSql(sql, params);
},
async clearSent(conversationId?: string): Promise<void> {
await ensureTable();
const sql = conversationId
? `DELETE FROM segment_outbox WHERE status = 'sent' AND conversation_id = ?`
: `DELETE FROM segment_outbox WHERE status = 'sent'`;
const params = conversationId ? [conversationId] : [];
await executeSql(sql, params);
},
async clearAll(conversationId: string): Promise<void> {
await ensureTable();
await executeSql(`DELETE FROM segment_outbox WHERE conversation_id = ?`, [
conversationId,
]);
},
/** For testing — reset the initialization flag. */
_resetForTest(): void {
initialized = false;
},
} as const;

View File

@@ -0,0 +1,52 @@
import type { SegmenterConfig } from './types';
type SegmentCallback = (segmentIndex: number) => void;
const DEFAULT_CONFIG: SegmenterConfig = {
strategy: 'fixed-duration',
fixedDurationMs: 15_000,
};
/**
* Time-based audio segmenter.
* Fires a callback at fixed intervals during recording,
* signaling that the current segment should be finalized and sent.
*
* First phase: fixed-duration only.
* Future: silence-based detection as an additional strategy.
*/
export class AudioSegmenter {
private config: SegmenterConfig;
private timer: ReturnType<typeof setInterval> | null = null;
private currentIndex = 0;
private onSegment: SegmentCallback;
constructor(onSegment: SegmentCallback, config?: Partial<SegmenterConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.onSegment = onSegment;
}
start(): void {
this.stop();
this.currentIndex = 0;
this.timer = setInterval(() => {
this.onSegment(this.currentIndex);
this.currentIndex++;
}, this.config.fixedDurationMs);
}
stop(): number {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
const finalIndex = this.currentIndex;
this.currentIndex = 0;
return finalIndex;
}
getCurrentIndex(): number {
return this.currentIndex;
}
}

View File

@@ -0,0 +1,37 @@
// ─── Recorder state ───
export type RecorderStatus = 'idle' | 'preparing' | 'recording' | 'stopping';
// ─── Segmenter ───
export type SegmentStrategy = 'fixed-duration';
export interface SegmenterConfig {
strategy: SegmentStrategy;
fixedDurationMs: number;
}
// ─── Segment outbox ───
export type SegmentOutboxStatus = 'pending' | 'sending' | 'sent' | 'failed';
export interface SegmentOutboxEntry {
id: number;
conversationId: string;
voiceSessionId: string;
segmentIndex: number;
fileUri: string;
durationMs: number;
status: SegmentOutboxStatus;
retryCount: number;
createdAt: number;
}
// ─── Player ───
export type PlayerStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
export interface PlaybackItem {
uri: string;
label?: string;
}