Add MindSpace page live edit, chat skills, and H5 deploy tooling.
Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import type { VoiceSessionMode } from './types';
|
||||
import { blobToWav16kMono } from './audioWav';
|
||||
|
||||
function blobExtension(mimeType: string) {
|
||||
if (mimeType.includes('wav')) return 'wav';
|
||||
if (mimeType.includes('webm')) return 'webm';
|
||||
if (mimeType.includes('mp4')) return 'm4a';
|
||||
if (mimeType.includes('ogg')) return 'ogg';
|
||||
if (mimeType.includes('wav')) return 'wav';
|
||||
return 'webm';
|
||||
return 'wav';
|
||||
}
|
||||
|
||||
async function parseAsrError(res: Response) {
|
||||
@@ -29,8 +30,9 @@ async function parseAsrError(res: Response) {
|
||||
|
||||
/** 一次性识别(push-to-talk);call 模式后续可扩展 openStream() */
|
||||
export async function transcribeOneShot(blob: Blob, _mode: VoiceSessionMode = 'push-to-talk') {
|
||||
const prepared = await blobToWav16kMono(blob);
|
||||
const form = new FormData();
|
||||
form.append('file', blob, `recording.${blobExtension(blob.type)}`);
|
||||
form.append('file', prepared, `recording.${blobExtension(prepared.type)}`);
|
||||
const res = await fetch('/api/asr/oneshot', { method: 'POST', body: form });
|
||||
if (!res.ok) throw new Error(await parseAsrError(res));
|
||||
const body = (await res.json()) as { data?: { text?: string } };
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
const TARGET_SAMPLE_RATE = 16_000;
|
||||
|
||||
function mixToMono(buffer: AudioBuffer): AudioBuffer {
|
||||
if (buffer.numberOfChannels === 1) return buffer;
|
||||
const mono = new AudioBuffer({
|
||||
length: buffer.length,
|
||||
numberOfChannels: 1,
|
||||
sampleRate: buffer.sampleRate,
|
||||
});
|
||||
const out = mono.getChannelData(0);
|
||||
const channels = buffer.numberOfChannels;
|
||||
for (let ch = 0; ch < channels; ch += 1) {
|
||||
const data = buffer.getChannelData(ch);
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
out[i] += data[i] / channels;
|
||||
}
|
||||
}
|
||||
return mono;
|
||||
}
|
||||
|
||||
function encodeWav(audioBuffer: AudioBuffer): Blob {
|
||||
const channel = audioBuffer.getChannelData(0);
|
||||
const dataSize = channel.length * 2;
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const writeString = (offset: number, value: string) => {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
view.setUint8(offset + i, value.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
writeString(0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString(8, 'WAVE');
|
||||
writeString(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, audioBuffer.sampleRate, true);
|
||||
view.setUint32(28, audioBuffer.sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(36, 'data');
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
let offset = 44;
|
||||
for (let i = 0; i < channel.length; i += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, channel[i] ?? 0));
|
||||
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
/** 将浏览器录音转为 16kHz 单声道 WAV,提升 ASR 解码成功率。失败时返回原 blob。 */
|
||||
export async function blobToWav16kMono(blob: Blob): Promise<Blob> {
|
||||
if (!blob.size) return blob;
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
try {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
let decoded: AudioBuffer;
|
||||
try {
|
||||
decoded = await audioContext.decodeAudioData(arrayBuffer.slice(0));
|
||||
} catch {
|
||||
return blob;
|
||||
}
|
||||
|
||||
const mono = mixToMono(decoded);
|
||||
const duration = mono.duration;
|
||||
if (!Number.isFinite(duration) || duration <= 0) return blob;
|
||||
|
||||
const offline = new OfflineAudioContext(
|
||||
1,
|
||||
Math.max(1, Math.ceil(duration * TARGET_SAMPLE_RATE)),
|
||||
TARGET_SAMPLE_RATE,
|
||||
);
|
||||
const source = offline.createBufferSource();
|
||||
source.buffer = mono;
|
||||
source.connect(offline.destination);
|
||||
source.start(0);
|
||||
const rendered = await offline.startRendering();
|
||||
if (!rendered.length) return blob;
|
||||
return encodeWav(rendered);
|
||||
} finally {
|
||||
await audioContext.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
import { isWechatBrowser } from '../utils/wechat';
|
||||
import { MicCapture } from './micCapture';
|
||||
import { isSpeechRecognitionSupported } from './speechRecognition';
|
||||
|
||||
/** 微信内置浏览器 Web Speech 不可用,直接走服务端 ASR。 */
|
||||
export function shouldPreferServerAsr(): boolean {
|
||||
return isWechatBrowser();
|
||||
}
|
||||
|
||||
/** Web Speech 出现这些错误时可改用服务端 ASR(录音 + 完成识别)。 */
|
||||
export function shouldFallbackToServerAsr(errorMessage: string): boolean {
|
||||
if (shouldPreferServerAsr()) return false;
|
||||
if (!MicCapture.isSupported()) return false;
|
||||
return /network|service-not-allowed|语音识别失败|无法启动语音识别/i.test(errorMessage);
|
||||
}
|
||||
|
||||
/** Whether voice input may work in this browser (mic and/or live speech). */
|
||||
export function isVoiceInputAvailable(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
if (MicCapture.isSupported()) return true;
|
||||
if (isSpeechRecognitionSupported()) return true;
|
||||
if (isSpeechRecognitionSupported() && !shouldPreferServerAsr()) return true;
|
||||
return Boolean(navigator.mediaDevices?.getUserMedia);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user