feat(M2.1): Web 端语音录放闭环

1. 新增 GET /api/chat/audio/file?path=<tmp_dir内路径> 路由
   - 路径白名单:canonicalize 后必须位于 config.ai.tmp_dir 内
   - 返回 audio/wav 二进制流(浏览器可直接 <audio> 播放)
   - 60s 后自动清理临时文件
   - 修复:原 /download/:filename 只能读 downloads_dir,TTS 输出在 tmp_dir 无法访问

2. POST /api/chat/audio 支持 X-Audio-Format 头
   - 浏览器 MediaRecorder 产 webm/ogg/m4a,whisper-cli 靠扩展名判断格式
   - 服务端按头指示决定文件扩展名(webm/ogg/m4a/mp3/wav)

3. chat.html 录音 UI + 客户端 WAV 转码
   - 按住🎙按钮录音(MediaRecorder),松开发送
   - 客户端用 Web Audio API 解码 + 重采样到 16k 单声道 PCM16
   - 手写 WAV header,输出标准 16k mono wav(whisper-cli 直接可处理)
   - 非安全上下文(HTTP)下禁用录音并显示 Chrome flag 启用指引
   - 回复音频改用 /api/chat/audio/file 路径播放
   - 显示 ASR 转写文本(🎤 你说: xxx)
This commit is contained in:
2026-07-06 14:18:17 +08:00
parent 8617a0e708
commit 5df99ced7b
2 changed files with 180 additions and 55 deletions

View File

@@ -77,6 +77,15 @@ h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
.tag.tts{background:rgba(245,158,11,.2);color:#fcd34d} .tag.tts{background:rgba(245,158,11,.2);color:#fcd34d}
.tag.downloaded{background:rgba(34,197,94,.2);color:#86efac} .tag.downloaded{background:rgba(34,197,94,.2);color:#86efac}
.tag.active{background:var(--green);color:#fff} .tag.active{background:var(--green);color:#fff}
.mic-btn{width:44px;height:44px;border-radius:50%;border:none;background:var(--surface2);color:var(--text);cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:.2s;flex-shrink:0}
.mic-btn:hover:not(:disabled){background:var(--accent);color:#fff}
.mic-btn.recording{background:var(--red);color:#fff;animation:micPulse 1s infinite}
.mic-btn:disabled{opacity:.4;cursor:not-allowed}
@keyframes micPulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.5)}50%{box-shadow:0 0 0 8px rgba(239,68,68,0)}}
.rec-tip{font-size:11px;color:var(--amber);margin-top:6px;line-height:1.5}
.rec-tip code{background:var(--surface2);padding:1px 5px;border-radius:3px;font-size:10px}
.audio-inline{margin-top:8px}
.audio-inline audio{width:100%;max-width:280px;height:32px}
</style> </style>
</head> </head>
<body> <body>
@@ -96,8 +105,9 @@ h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
<div class="panel active" id="panel-chat"> <div class="panel active" id="panel-chat">
<div id="renderArea"></div> <div id="renderArea"></div>
<div class="chat-box" id="chatBox" style="display:none"></div> <div class="chat-box" id="chatBox" style="display:none"></div>
<div class="chat-input"><input type="text" id="chatInput" placeholder="输入文字与角色对话..." autocomplete="off"><button id="sendBtn" onclick="sendChat()">发送</button></div> <div class="chat-input"><input type="text" id="chatInput" placeholder="输入文字与角色对话..." autocomplete="off"><button id="sendBtn" onclick="sendChat()">发送</button><button class="mic-btn" id="micBtn" title="按住说话(松开发送)" disabled>🎙</button></div>
<div class="status-bar" id="chatStatus"></div> <div class="status-bar" id="chatStatus"></div>
<div class="rec-tip" id="recTip"></div>
</div> </div>
<div class="panel" id="panel-character"> <div class="panel" id="panel-character">
<div style="margin-bottom:12px;font-size:13px;color:var(--muted)">不同角色类型采用不同渲染和对话策略pet(视频+拟声) / human(视频+自然) / singer(视频+元气) / live2d(Canvas渲染+动效)</div> <div style="margin-bottom:12px;font-size:13px;color:var(--muted)">不同角色类型采用不同渲染和对话策略pet(视频+拟声) / human(视频+自然) / singer(视频+元气) / live2d(Canvas渲染+动效)</div>
@@ -113,7 +123,10 @@ const $=s=>document.querySelector(s);const sessionId='web_'+Date.now();let chatB
const charStrategies={pet:{icon:'🐶',label:'宠物',render:'video',placeholder:'和宠物说说话...'},human:{icon:'🧑',label:'数字人',render:'video',placeholder:'和数字人对话...'},singer:{icon:'🎤',label:'歌姬',render:'video',placeholder:'和歌姬聊天...'},live2d:{icon:'✨',label:'Live2D数字人',render:'live2d',placeholder:'和Live2D数字人对话...'}}; const charStrategies={pet:{icon:'🐶',label:'宠物',render:'video',placeholder:'和宠物说说话...'},human:{icon:'🧑',label:'数字人',render:'video',placeholder:'和数字人对话...'},singer:{icon:'🎤',label:'歌姬',render:'video',placeholder:'和歌姬聊天...'},live2d:{icon:'✨',label:'Live2D数字人',render:'live2d',placeholder:'和Live2D数字人对话...'}};
document.querySelectorAll('.tab').forEach(t=>{t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');$('#panel-'+t.dataset.tab).classList.add('active');if(t.dataset.tab==='character')loadCharacters();if(t.dataset.tab==='models')loadModels();};}); document.querySelectorAll('.tab').forEach(t=>{t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');$('#panel-'+t.dataset.tab).classList.add('active');if(t.dataset.tab==='character')loadCharacters();if(t.dataset.tab==='models')loadModels();};});
function applyCharStrategy(char){currentChar=char;const strat=charStrategies[char.character_type||'pet']||charStrategies.pet;$('#charAvatar').textContent=strat.icon;$('#charName').textContent=char.name||'未知角色';const tb=$('#charType');tb.textContent=strat.label;tb.className='type-badge '+(char.character_type||'pet');$('#charRender').textContent=char.render_type||strat.render;const ra=$('#renderArea');const cb=$('#chatBox');if((char.render_type||strat.render)==='live2d'){cb.style.display='none';ra.innerHTML='<div class="live2d-container" id="live2dContainer"><div class="live2d-placeholder"><div class="icon">✨</div>Live2D 渲染区<br><small>模型: '+(char.live2d_model||'未配置')+'</small></div></div>';}else{ra.innerHTML='';cb.style.display='block';}$('#chatInput').placeholder=strat.placeholder;} function applyCharStrategy(char){currentChar=char;const strat=charStrategies[char.character_type||'pet']||charStrategies.pet;$('#charAvatar').textContent=strat.icon;$('#charName').textContent=char.name||'未知角色';const tb=$('#charType');tb.textContent=strat.label;tb.className='type-badge '+(char.character_type||'pet');$('#charRender').textContent=char.render_type||strat.render;const ra=$('#renderArea');const cb=$('#chatBox');if((char.render_type||strat.render)==='live2d'){cb.style.display='none';ra.innerHTML='<div class="live2d-container" id="live2dContainer"><div class="live2d-placeholder"><div class="icon">✨</div>Live2D 渲染区<br><small>模型: '+(char.live2d_model||'未配置')+'</small></div></div>';}else{ra.innerHTML='';cb.style.display='block';}$('#chatInput').placeholder=strat.placeholder;}
async function sendChat(){const input=$('#chatInput');const text=input.value.trim();if(!text||chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;$('#chatStatus').textContent='思考中...';$('#chatStatus').className='status-bar busy';input.value='';addMsg('user',text);if(currentChar.render_type!=='live2d'&&currentChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const res=await fetch('/api/chat/text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,session_id:sessionId})});const data=await res.json();if(data.error){botMsg.querySelector('.bubble').textContent='错误: '+data.error;botMsg.querySelector('.bubble').style.color='var(--red)';$('#chatStatus').textContent='错误: '+data.error;$('#chatStatus').className='status-bar error';}else{botMsg.querySelector('.bubble').textContent=data.reply_text||'(空回复)';if(data.reply_audio_path){const au='/download/'+encodeURIComponent(data.reply_audio_path.replace(/^\//,''));const ad=document.createElement('div');ad.className='audio';ad.innerHTML='<span>🔊</span> <audio controls src="'+au+'"></audio>';botMsg.appendChild(ad);}$('#chatStatus').textContent='';$('#chatStatus').className='status-bar';const rl=(data.reply_text||'').length;const tm=Math.max(2000,rl*200);setTimeout(()=>{triggerTalkState(false);showLive2dTalking(false);},tm);}}catch(e){botMsg.querySelector('.bubble').textContent='请求失败: '+e.message;botMsg.querySelector('.bubble').style.color='var(--red)';$('#chatStatus').textContent='请求失败: '+e.message;$('#chatStatus').className='status-bar error';triggerTalkState(false);showLive2dTalking(false);}chatBusy=false;$('#sendBtn').disabled=false;$('#chatInput').focus();} async function sendChat(){const input=$('#chatInput');const text=input.value.trim();if(!text||chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;$('#chatStatus').textContent='思考中...';$('#chatStatus').className='status-bar busy';input.value='';addMsg('user',text);if(currentChar.render_type!=='live2d'&&currentChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const res=await fetch('/api/chat/text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,session_id:sessionId})});const data=await res.json();handleChatResponse(data,botMsg);}catch(e){showChatError(botMsg,'请求失败: '+e.message);}chatBusy=false;$('#sendBtn').disabled=false;$('#chatInput').focus();}
function audioUrl(path){return '/api/chat/audio/file?path='+encodeURIComponent(path);}
function handleChatResponse(data,botMsg){if(data.error){showChatError(botMsg,'错误: '+data.error);return;}botMsg.querySelector('.bubble').textContent=data.reply_text||(data.transcription?'(无回复)':'(空回复)');if(data.transcription&&data.transcription.trim()){const tr=document.createElement('div');tr.className='meta';tr.style.color='var(--muted)';tr.textContent='🎤 你说: '+data.transcription;botMsg.querySelector('.bubble').insertAdjacentElement('beforebegin',tr);}if(data.reply_audio_path){const ad=document.createElement('div');ad.className='audio-inline';ad.innerHTML='<span style="font-size:12px;color:var(--muted)">🔊 回复语音</span><audio controls src="'+audioUrl(data.reply_audio_path)+'"></audio>';botMsg.appendChild(ad);}$('#chatStatus').textContent='';$('#chatStatus').className='status-bar';const rl=(data.reply_text||'').length;const tm=Math.max(2000,rl*200);setTimeout(()=>{triggerTalkState(false);showLive2dTalking(false);},tm);}
function showChatError(botMsg,msg){botMsg.querySelector('.bubble').textContent=msg;botMsg.querySelector('.bubble').style.color='var(--red)';$('#chatStatus').textContent=msg;$('#chatStatus').className='status-bar error';triggerTalkState(false);showLive2dTalking(false);}
function triggerTalkState(talking){const scene=talking?(currentChar.talk_state||'talk'):'idle';fetch('/api/scene',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scene})}).catch(()=>{});} function triggerTalkState(talking){const scene=talking?(currentChar.talk_state||'talk'):'idle';fetch('/api/scene',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scene})}).catch(()=>{});}
function showLive2dTalking(talking){const c=$('#live2dContainer');if(!c)return;const ex=c.querySelector('.live2d-talking');if(talking&&!ex){const b=document.createElement('div');b.className='live2d-talking';b.textContent='说话中';c.appendChild(b);}else if(!talking&&ex)ex.remove();} function showLive2dTalking(talking){const c=$('#live2dContainer');if(!c)return;const ex=c.querySelector('.live2d-talking');if(talking&&!ex){const b=document.createElement('div');b.className='live2d-talking';b.textContent='说话中';c.appendChild(b);}else if(!talking&&ex)ex.remove();}
function addMsg(role,text){const box=$('#chatBox');const div=document.createElement('div');div.className='msg '+role;const t=new Date().toLocaleTimeString('zh-CN',{hour12:false});div.innerHTML='<div class="bubble"></div><div class="meta">'+t+'</div>';div.querySelector('.bubble').textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div;} function addMsg(role,text){const box=$('#chatBox');const div=document.createElement('div');div.className='msg '+role;const t=new Date().toLocaleTimeString('zh-CN',{hour12:false});div.innerHTML='<div class="bubble"></div><div class="meta">'+t+'</div>';div.querySelector('.bubble').textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div;}
@@ -125,6 +138,12 @@ async function loadModels(){try{const res=await fetch('/api/models');const data=
async function downloadModel(id){try{const res=await fetch('/api/models/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'下载已启动');setTimeout(loadModels,3000);}catch(e){alert('失败');}} async function downloadModel(id){try{const res=await fetch('/api/models/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'下载已启动');setTimeout(loadModels,3000);}catch(e){alert('失败');}}
async function switchModel(id,kind){try{const res=await fetch('/api/models/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id,kind})});const data=await res.json();alert(data.message||'已切换');loadModels();}catch(e){alert('失败');}} async function switchModel(id,kind){try{const res=await fetch('/api/models/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id,kind})});const data=await res.json();alert(data.message||'已切换');loadModels();}catch(e){alert('失败');}}
async function deleteModel(id){if(!confirm('删除 '+id+'?'))return;try{const res=await fetch('/api/models/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'已删除');loadModels();}catch(e){alert('失败');}} async function deleteModel(id){if(!confirm('删除 '+id+'?'))return;try{const res=await fetch('/api/models/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'已删除');loadModels();}catch(e){alert('失败');}}
initMic();
async function initMic(){const micBtn=$('#micBtn');const tip=$('#recTip');if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){tip.innerHTML='当前浏览器不支持录音。请使用文字对话。';return;}if(!window.isSecureContext){micBtn.disabled=true;tip.innerHTML='⚠️ 录音需要安全上下文HTTPS 或 localhost。当前为 HTTP 访问,录音已禁用。<br>启用方法(桌面 Chrome/Edge右键浏览器图标 → 属性 → 在目标末尾加 <code>--unsafely-treat-insecure-origin-as-secure=http://192.168.31.105:5000</code> → 重启浏览器并访问本页。<br>或直接用文字对话,回复语音仍可正常播放。';return;}let mediaRecorder=null;let chunks=[];let recStream=null;let recTimer=null;const startRec=async()=>{if(chatBusy)return;try{recStream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,sampleRate:16000,echoCancellation:true,noiseSuppression:true}});chunks=[];mediaRecorder=new MediaRecorder(recStream);mediaRecorder.ondataavailable=e=>{if(e.data.size>0)chunks.push(e.data);};mediaRecorder.onstop=()=>{if(recTimer){clearTimeout(recTimer);recTimer=null;}recStream.getTracks().forEach(t=>t.stop());recStream=null;const blob=new Blob(chunks,{type:mediaRecorder.mimeType||'audio/webm'});if(blob.size<1000){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('#chatStatus').textContent='录音过短,已取消';$('#chatStatus').className='status-bar error';return;}sendAudioBlob(blob,mediaRecorder.mimeType||'audio/webm');};mediaRecorder.start();micBtn.classList.add('recording');micBtn.textContent='⏹';$('#chatStatus').textContent='录音中...(松开发送)';$('#chatStatus').className='status-bar busy';recTimer=setTimeout(()=>{if(mediaRecorder&&mediaRecorder.state==='recording'){stopRec();$('#chatStatus').textContent='录音达到 30s 上限,自动发送';}},30000);}catch(e){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('#chatStatus').textContent='麦克风启动失败: '+e.message;$('#chatStatus').className='status-bar error';}};const stopRec=()=>{if(mediaRecorder&&mediaRecorder.state==='recording'){mediaRecorder.stop();micBtn.classList.remove('recording');micBtn.textContent='🎙';}};micBtn.disabled=false;micBtn.addEventListener('mousedown',startRec);micBtn.addEventListener('touchstart',e=>{e.preventDefault();startRec();});micBtn.addEventListener('mouseup',stopRec);micBtn.addEventListener('mouseleave',stopRec);micBtn.addEventListener('touchend',e=>{e.preventDefault();stopRec();});}
async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;const micBtn=$('#micBtn');micBtn.disabled=true;$('#chatStatus').textContent='转码中...';$('#chatStatus').className='status-bar busy';const userMsg=addMsg('user','🎤 [语音消息]');const playUrl=URL.createObjectURL(blob);const up=document.createElement('div');up.className='audio-inline';up.innerHTML='<span style="font-size:11px;color:var(--muted)">你的录音</span><audio controls src="'+playUrl+'"></audio>';userMsg.appendChild(up);if(currentChar.render_type!=='live2d'&&currentChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const wavBlob=await blobToWav(blob);$('#chatStatus').textContent='识别中...';const res=await fetch('/api/chat/audio',{method:'POST',headers:{'Content-Type':'audio/wav','X-Audio-Format':'wav'},body:wavBlob});const data=await res.json();handleChatResponse(data,botMsg);}catch(e){showChatError(botMsg,'请求失败: '+e.message);}chatBusy=false;$('#sendBtn').disabled=false;micBtn.disabled=false;}
async function blobToWav(blob){const arrayBuf=await blob.arrayBuffer();const audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:16000});const decoded=await audioCtx.decodeAudioData(arrayBuf);const pcm=decodeToMonoPCM16(decoded,16000);audioCtx.close();return encodeWavBlob(pcm,16000);}
function decodeToMonoPCM16(audioBuffer,targetRate){const numCh=audioBuffer.numberOfChannels;const len=audioBuffer.length;const srcRate=audioBuffer.sampleRate;const outLen=Math.round(len*targetRate/srcRate);const out=new Int16Array(outLen);const tmp=new Float32Array(outLen);for(let ch=0;ch<numCh;ch++){const data=audioBuffer.getChannelData(ch);for(let i=0;i<outLen;i++){const srcIdx=i*srcRate/targetRate;const i0=Math.floor(srcIdx);const i1=Math.min(i0+1,len-1);const frac=srcIdx-i0;tmp[i]+=data[i0]*(1-frac)+data[i1]*frac;}}for(let i=0;i<outLen;i++){let s=tmp[i]/numCh;s=Math.max(-1,Math.min(1,s));out[i]=s<0?s*0x8000:s*0x7FFF;}return out;}
function encodeWavBlob(samples,sampleRate){const buf=new ArrayBuffer(44+samples.length*2);const view=new DataView(buf);const writeStr=(off,s)=>{for(let i=0;i<s.length;i++)view.setUint8(off+i,s.charCodeAt(i));};writeStr(0,'RIFF');view.setUint32(4,36+samples.length*2,true);writeStr(8,'WAVE');writeStr(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,1,true);view.setUint16(22,1,true);view.setUint32(24,sampleRate,true);view.setUint32(28,sampleRate*2,true);view.setUint16(32,2,true);view.setUint16(34,16,true);writeStr(36,'data');view.setUint32(40,samples.length*2,true);let off=44;for(let i=0;i<samples.length;i++){view.setInt16(off,samples[i],true);off+=2;}return new Blob([buf],{type:'audio/wav'});}
loadCurrentChar(); loadCurrentChar();
</script> </script>
</body> </body>

View File

@@ -7,6 +7,7 @@ use futures_util::{SinkExt, StreamExt, TryStreamExt};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap;
use std::convert::Infallible; use std::convert::Infallible;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{mpsc, Arc}; use std::sync::{mpsc, Arc};
@@ -147,6 +148,7 @@ pub(crate) fn build_routes(
let ai_api = chat_text_route(Arc::clone(&state)) let ai_api = chat_text_route(Arc::clone(&state))
.or(chat_audio_route(Arc::clone(&state))) .or(chat_audio_route(Arc::clone(&state)))
.or(chat_audio_file_route(Arc::clone(&state)))
.or(models_list_route(Arc::clone(&state))) .or(models_list_route(Arc::clone(&state)))
.or(model_download_route(Arc::clone(&state))) .or(model_download_route(Arc::clone(&state)))
.or(model_switch_route(Arc::clone(&state))) .or(model_switch_route(Arc::clone(&state)))
@@ -2031,7 +2033,11 @@ fn chat_text_route(
}) })
} }
/// POST /api/chat/audio — 语音对话App 主路径,上传 wav /// POST /api/chat/audio — 语音对话App/Web 主路径,上传 wav/webm/ogg 等浏览器录音
///
/// 注意:上传的音频会被传给 whisper-cli它通过文件扩展名判断格式。
/// 浏览器 MediaRecorder 默认产 webm/opus移动端可能产 m4a/aac。
/// 服务端不强校验格式,但建议客户端尽量转 wav/16k 单声道。
fn chat_audio_route( fn chat_audio_route(
state: Arc<HttpState>, state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone { ) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
@@ -2039,8 +2045,12 @@ fn chat_audio_route(
.and(warp::post()) .and(warp::post())
.and(warp::body::content_length_limit(20 * 1024 * 1024)) .and(warp::body::content_length_limit(20 * 1024 * 1024))
.and(warp::body::bytes()) .and(warp::body::bytes())
.and(warp::header::optional::<String>("X-Audio-Format"))
.and(with_state(state)) .and(with_state(state))
.and_then(|bytes: bytes::Bytes, state: Arc<HttpState>| async move { .and_then(
|bytes: bytes::Bytes,
fmt_hint: Option<String>,
state: Arc<HttpState>| async move {
let pipeline = match state.ai_pipeline() { let pipeline = match state.ai_pipeline() {
Some(p) => p, Some(p) => p,
None => { None => {
@@ -2054,8 +2064,17 @@ fn chat_audio_route(
let config = state.config(); let config = state.config();
let tmp_dir = config.ai.tmp_dir.clone(); let tmp_dir = config.ai.tmp_dir.clone();
let _ = std::fs::create_dir_all(&tmp_dir); let _ = std::fs::create_dir_all(&tmp_dir);
// 根据客户端上报的格式或默认 wav 决定扩展名whisper-cli 靠扩展名判断)
let ext = match fmt_hint.as_deref() {
Some("webm") => "webm",
Some("ogg") => "ogg",
Some("m4a") => "m4a",
Some("mp3") => "mp3",
Some("wav") | _ => "wav",
};
let audio_path = tmp_dir.join(format!( let audio_path = tmp_dir.join(format!(
"asr_{}.wav", "asr_{}.{ext}",
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis()) .map(|d| d.as_millis())
@@ -2097,7 +2116,94 @@ fn chat_audio_route(
} else { } else {
Ok(json_response(StatusCode::OK, &resp)) Ok(json_response(StatusCode::OK, &resp))
} }
}) },
)
}
/// GET /api/chat/audio/file?path=<绝对路径> — 读取 TTS 生成的临时回复音频
///
/// 安全path 必须在 config.ai.tmp_dir 内,且文件存在。返回 wav 二进制流。
fn chat_audio_file_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "chat" / "audio" / "file")
.and(warp::get())
.and(warp::query::<HashMap<String, String>>())
.and(with_state(state))
.and_then(
|params: HashMap<String, String>, state: Arc<HttpState>| async move {
let raw_path = match params.get("path") {
Some(p) => p.clone(),
None => {
return Ok::<_, Infallible>(
error_json(StatusCode::BAD_REQUEST, "缺少 path 参数"),
)
}
};
let config = state.config();
let tmp_dir = config.ai.tmp_dir.clone();
// 路径白名单:必须规范化后位于 tmp_dir 内
let candidate = std::path::Path::new(&raw_path);
let canonical_target = match candidate.canonicalize() {
Ok(p) => p,
Err(_) => {
return Ok(error_json(
StatusCode::NOT_FOUND,
"音频文件不存在或无法访问",
))
}
};
let canonical_tmp = match tmp_dir.canonicalize() {
Ok(p) => p,
Err(_) => {
return Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
"tmp_dir 未配置或不存在",
))
}
};
if !canonical_target.starts_with(&canonical_tmp) {
return Ok(error_json(
StatusCode::FORBIDDEN,
"路径越界:只允许访问 tmp_dir 内的音频文件",
));
}
if !canonical_target.is_file() {
return Ok(error_json(StatusCode::NOT_FOUND, "音频文件不存在"));
}
// 返回内联音频(浏览器可直接播放)
match std::fs::read(&canonical_target) {
Ok(data) => {
let cleanup_target = canonical_target.clone();
let _ = std::thread::spawn(move || {
// 60s 后清理(足够浏览器播放完)
std::thread::sleep(std::time::Duration::from_secs(60));
let _ = std::fs::remove_file(cleanup_target);
});
match warp::http::Response::builder()
.header("Content-Type", "audio/wav")
.header("Content-Length", data.len().to_string())
.header("Cache-Control", "no-store")
.body(data)
{
Ok(response) => Ok(response.into_response()),
Err(_) => Ok(warp::reply::with_status(
warp::reply::html("响应构建失败".to_string()),
StatusCode::INTERNAL_SERVER_ERROR,
)
.into_response()),
}
}
Err(e) => Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("读取音频失败: {e}"),
)),
}
},
)
} }
// ── AI 模型管理 API (M2.1) ── // ── AI 模型管理 API (M2.1) ──