fix(hermes-voice): finalize mobile audio and reduce latency
This commit is contained in:
parent
79369c2357
commit
ef32843a76
@ -223,8 +223,6 @@
|
||||
const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/webm'];
|
||||
const mime=mimeTypes.find(function(value){return MediaRecorder.isTypeSupported(value);})||'';
|
||||
const chunks=[];
|
||||
const preRoll=[];
|
||||
let initialChunk=null;
|
||||
let heardSpeech=false;
|
||||
let voiceFrames=0;
|
||||
let noiseFloor=0.008;
|
||||
@ -235,13 +233,7 @@
|
||||
recorder.ondataavailable=function(event){
|
||||
if(!event.data||!event.data.size) return;
|
||||
if(event.data.type) recordedMime=event.data.type;
|
||||
if(heardSpeech){chunks.push(event.data);return;}
|
||||
// MediaRecorder's first timeslice owns the container initialization
|
||||
// (EBML/Opus headers for WebM, and equivalent headers for Ogg/MP4).
|
||||
// Keep it separately while bounding the actual audio pre-roll.
|
||||
if(!initialChunk){initialChunk=event.data;return;}
|
||||
preRoll.push(event.data);
|
||||
while(preRoll.length>3) preRoll.shift();
|
||||
chunks.push(event.data);
|
||||
};
|
||||
recorder.onstop=function(){
|
||||
if(vadTimer){clearInterval(vadTimer);vadTimer=null;}
|
||||
@ -254,7 +246,12 @@
|
||||
if(!heardSpeech||!chunks.length){restartSoon(token,300);return;}
|
||||
transcribe(new Blob(chunks,{type:recordedMime||'audio/webm'}),token);
|
||||
};
|
||||
recorder.start(250);
|
||||
// Ask the browser for one finalized container at stop. Android Chromium
|
||||
// can emit timeslice fragments without a reusable EBML initialization
|
||||
// header; concatenating those fragments made otherwise valid recordings
|
||||
// intermittently unreadable by ffmpeg. A bounded 90-second Opus capture
|
||||
// is small enough to retain as one browser-owned recording.
|
||||
recorder.start();
|
||||
const silenceMs=Math.max(900,parseInt(localStorage.getItem('hermes-voice-silence-ms')||'1600',10)||1600);
|
||||
vadTimer=window.setInterval(function(){
|
||||
if(!active||token!==generation||!recorder||recorder.state==='inactive') return;
|
||||
@ -274,8 +271,6 @@
|
||||
if(!heardSpeech&&voiceFrames>=3){
|
||||
heardSpeech=true;
|
||||
lastSpeech=now;
|
||||
if(initialChunk){chunks.push(initialChunk);initialChunk=null;}
|
||||
while(preRoll.length) chunks.push(preRoll.shift());
|
||||
}else if(heardSpeech&&voiceNow){
|
||||
lastSpeech=now;
|
||||
}
|
||||
@ -352,7 +347,9 @@
|
||||
const text=cleanForSpeech(rows[rows.length-1].dataset.rawText||'');
|
||||
if(!text){restartSoon(token,250);return;}
|
||||
setState('speaking');
|
||||
const chunks=typeof window._splitForTTS==='function'?window._splitForTTS(text,280):[text];
|
||||
// Shorter first chunks lower time-to-first-audio; the next chunk is
|
||||
// synthesized while the current one plays.
|
||||
const chunks=typeof window._splitForTTS==='function'?window._splitForTTS(text,160):[text];
|
||||
try{
|
||||
let pending=fetchSpeech(chunks[0],language);
|
||||
for(let index=0;index<chunks.length;index+=1){
|
||||
|
||||
@ -132,7 +132,7 @@ spec:
|
||||
- {name: XDG_CACHE_HOME, value: /tmp/cache}
|
||||
- {name: HERMES_TTS_PORT, value: "9001"}
|
||||
- {name: HERMES_TTS_CACHE, value: /opt/models/piper}
|
||||
- {name: HERMES_TTS_ONNX_THREADS, value: "2"}
|
||||
- {name: HERMES_TTS_ONNX_THREADS, value: "4"}
|
||||
startupProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 5
|
||||
|
||||
@ -310,10 +310,7 @@ async function normalMotionContract(scriptPath, mediaFixture) {
|
||||
assert.ok(probe.uploadedFile, 'transcription upload was not captured');
|
||||
assert.equal(probe.uploadedFile.name, 'voice-input.webm');
|
||||
assert.equal(probe.uploadedFile.type, mediaFixture.mime_type);
|
||||
const expectedUpload = Buffer.concat([
|
||||
mediaChunks[0],
|
||||
...mediaChunks.slice(preSpeechCount - 3),
|
||||
]);
|
||||
const expectedUpload = Buffer.concat(mediaChunks);
|
||||
assert.deepEqual(Buffer.from(probe.uploadedFile.bytes), expectedUpload);
|
||||
|
||||
probe.resolveTranscript({transcript: 'Hello Hermes'});
|
||||
|
||||
@ -342,13 +342,14 @@ def test_chat_voice_uses_private_jetson_services_and_shared_auto_route():
|
||||
assert "/api/transcribe" in voice_script
|
||||
assert "/api/tts" in voice_script
|
||||
assert "speakResponse(generation)" in voice_script
|
||||
assert "window._splitForTTS(text,280)" in voice_script
|
||||
assert "window._splitForTTS(text,160)" in voice_script
|
||||
assert "pending=fetchSpeech(chunks[index+1],language)" in voice_script
|
||||
assert "restartSoon(token,450)" in voice_script
|
||||
assert "constraints.voiceIsolation=true" in voice_script
|
||||
assert "highpass.frequency.value=140" in voice_script
|
||||
assert "Math.max(0.04,noiseFloor*2.4+0.006)" in voice_script
|
||||
assert "while(preRoll.length>3) preRoll.shift()" in voice_script
|
||||
assert "recorder.start();" in voice_script
|
||||
assert "recorder.start(250)" not in voice_script
|
||||
|
||||
stt_server = (ROOT / "dockerfiles" / "hermes-jetson-stt-server.py").read_text()
|
||||
assert "def _repetitive_token" in stt_server
|
||||
@ -466,7 +467,7 @@ def test_voice_workloads_have_deliberate_xavier_placement():
|
||||
# Keep the deployment neutral so the old and new immutable images can
|
||||
# cross one Flux rollout safely; each image owns its compatible default.
|
||||
assert "HERMES_TTS_VOICE" not in tts_env
|
||||
assert tts_env["HERMES_TTS_ONNX_THREADS"] == "2"
|
||||
assert tts_env["HERMES_TTS_ONNX_THREADS"] == "4"
|
||||
assert tts["containers"][0]["resources"]["limits"]["cpu"] == "4"
|
||||
assert all("hostPath" not in volume for volume in stt["volumes"])
|
||||
assert all("hostPath" not in volume for volume in tts["volumes"])
|
||||
|
||||
@ -92,14 +92,15 @@ def test_fixture_reproduces_the_current_ebml_header_failure(tmp_path: Path):
|
||||
assert "EBML header" in result.stderr
|
||||
|
||||
|
||||
def test_header_preserved_browser_webm_reaches_real_conversion_boundary(
|
||||
def test_complete_browser_recording_reaches_real_conversion_boundary(
|
||||
tmp_path: Path,
|
||||
):
|
||||
module = _patched_transcription_module(tmp_path)
|
||||
_fixture, chunks = _media_chunks()
|
||||
prepared, error = _convert(
|
||||
module,
|
||||
tmp_path,
|
||||
_late_speech_upload(preserve_header=True),
|
||||
b"".join(chunks),
|
||||
".webm",
|
||||
)
|
||||
|
||||
@ -164,8 +165,11 @@ def test_local_conversion_accepts_browser_fallback_containers(tmp_path: Path):
|
||||
def test_browser_uses_actual_recorder_mime_and_supported_extensions():
|
||||
source = VOICE_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert "let initialChunk=null" in source
|
||||
assert "if(initialChunk){chunks.push(initialChunk);initialChunk=null;}" in source
|
||||
assert "recorder.start();" in source
|
||||
assert "recorder.start(250)" not in source
|
||||
assert "chunks.push(event.data)" in source
|
||||
assert "initialChunk" not in source
|
||||
assert "preRoll" not in source
|
||||
assert "let recordedMime=recorder.mimeType||mime||''" in source
|
||||
assert "if(event.data.type) recordedMime=event.data.type" in source
|
||||
assert "audio/mp4;codecs=mp4a.40.2" in source
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user