[文档、验收与归档]:完成真实运行说明和OpenSpec归档,包含README、最终门禁和主规范更新

This commit is contained in:
mkbk
2026-06-17 20:17:04 +08:00
parent 4b7cd18a0f
commit 445eebeaec
15 changed files with 320 additions and 143 deletions
+4 -4
View File
@@ -13,7 +13,7 @@ class AppConfig:
channels: int = 1
llm_base_url: str = "https://token-plan-cn.xiaomimimo.com/v1"
llm_api_key: str | None = None
llm_model: str = "gpt-5.4-mini"
llm_model: str = "mimo-v2.5"
llm_api_style: str = "chat_completions"
llm_stream: bool = True
audio_input_device: str | None = None
@@ -23,7 +23,7 @@ class AppConfig:
speech_provider: str = "cloud"
asr_model: str = "mimo-v2.5-asr"
tts_model: str = "mimo-v2.5-tts"
tts_voice: str = "alloy"
tts_voice: str = "mimo_default"
speech_models_dir: Path = Path("models")
context_max_messages: int = 12
context_max_chars: int = 12000
@@ -42,7 +42,7 @@ class AppConfig:
channels=int(get("CHANNELS", "1") or "1"),
llm_base_url=(get("LLM_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1") or "").rstrip("/"),
llm_api_key=get("LLM_API_KEY"),
llm_model=get("LLM_MODEL", "gpt-5.4-mini") or "gpt-5.4-mini",
llm_model=get("LLM_MODEL", "mimo-v2.5") or "mimo-v2.5",
llm_api_style=get("LLM_API_STYLE", "chat_completions") or "chat_completions",
llm_stream=(get("LLM_STREAM", "1") or "1").lower() not in {"0", "false", "no"},
audio_input_device=get("AUDIO_INPUT_DEVICE"),
@@ -52,7 +52,7 @@ class AppConfig:
speech_provider=(get("SPEECH_PROVIDER", "cloud") or "cloud").lower(),
asr_model=get("ASR_MODEL", "mimo-v2.5-asr") or "mimo-v2.5-asr",
tts_model=get("TTS_MODEL", "mimo-v2.5-tts") or "mimo-v2.5-tts",
tts_voice=get("TTS_VOICE", "alloy") or "alloy",
tts_voice=get("TTS_VOICE", "mimo_default") or "mimo_default",
speech_models_dir=Path(get("SPEECH_MODELS_DIR", "models") or "models"),
context_max_messages=int(get("CONTEXT_MAX_MESSAGES", "12") or "12"),
context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"),
+23 -36
View File
@@ -1,12 +1,12 @@
from __future__ import annotations
import io
import base64
import json
import re
import socket
import urllib.error
import urllib.request
import uuid
import wave
from collections.abc import Callable
from pathlib import Path
@@ -84,19 +84,28 @@ class CloudAsrSttProvider:
"newapi-asr",
"stt",
)
wav_bytes = _segment_to_wav_bytes(segment)
boundary = "owner-voice-pet-" + uuid.uuid4().hex
body = _multipart_form_data(
boundary,
fields={"model": self.config.asr_model, "response_format": "json"},
files={"file": ("utterance.wav", "audio/wav", wav_bytes)},
)
audio_data = base64.b64encode(_segment_to_wav_bytes(segment)).decode("ascii")
payload = {
"model": self.config.asr_model,
"messages": [
{
"role": "user",
"content": [
{
"type": "input_audio",
"input_audio": {"data": f"data:audio/wav;base64,{audio_data}"},
}
],
}
],
"asr_options": {"language": "auto"},
}
request = urllib.request.Request(
self.config.api_url("/v1/audio/transcriptions"),
data=body,
self.config.api_url("/v1/chat/completions"),
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {self.config.llm_api_key}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Type": "application/json",
},
method="POST",
)
@@ -119,7 +128,9 @@ class CloudAsrSttProvider:
"newapi-asr",
"stt",
) from exc
text = str(payload.get("text") or "").strip()
choices = payload.get("choices") or []
message = (choices[0].get("message") if choices else {}) or {}
text = str(message.get("content") or payload.get("text") or "").strip()
if not is_valid_transcript_text(text):
raise ProviderError(
ErrorCode.STT_EMPTY_TRANSCRIPT,
@@ -261,27 +272,3 @@ def _segment_to_wav_bytes(segment: AudioSegment) -> bytes:
wav.setframerate(segment.sample_rate)
wav.writeframes(segment.pcm)
return buffer.getvalue()
def _multipart_form_data(
boundary: str,
*,
fields: dict[str, str],
files: dict[str, tuple[str, str, bytes]],
) -> bytes:
body = bytearray()
for name, value in fields.items():
body.extend(f"--{boundary}\r\n".encode())
body.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
body.extend(value.encode("utf-8"))
body.extend(b"\r\n")
for name, (filename, content_type, data) in files.items():
body.extend(f"--{boundary}\r\n".encode())
body.extend(
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode()
)
body.extend(f"Content-Type: {content_type}\r\n\r\n".encode())
body.extend(data)
body.extend(b"\r\n")
body.extend(f"--{boundary}--\r\n".encode())
return bytes(body)
+23 -8
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import math
import base64
import json
import socket
import struct
@@ -166,12 +167,12 @@ class CloudTtsProvider:
)
payload = {
"model": self.config.tts_model,
"input": clean,
"voice": self.config.tts_voice,
"response_format": "mp3",
"messages": [{"role": "assistant", "content": clean}],
"audio": {"format": "wav", "voice": self.config.tts_voice},
"stream": False,
}
request = urllib.request.Request(
self.config.api_url("/v1/audio/speech"),
self.config.api_url("/v1/chat/completions"),
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {self.config.llm_api_key}",
@@ -181,7 +182,7 @@ class CloudTtsProvider:
)
try:
with self.urlopen(request, timeout=self.timeout_s) as response:
data = response.read()
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
raise ProviderError(
ErrorCode.TTS_SYNTHESIS_FAILED,
@@ -190,7 +191,7 @@ class CloudTtsProvider:
"newapi-tts",
"tts",
) from exc
except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
except (urllib.error.URLError, TimeoutError, socket.timeout, json.JSONDecodeError) as exc:
raise ProviderError(
ErrorCode.TTS_SYNTHESIS_FAILED,
f"cloud TTS request failed: {exc}",
@@ -198,7 +199,11 @@ class CloudTtsProvider:
"newapi-tts",
"tts",
) from exc
if not data:
choices = payload.get("choices") or []
message = (choices[0].get("message") if choices else {}) or {}
audio = message.get("audio") or {}
encoded = audio.get("data")
if not encoded:
raise ProviderError(
ErrorCode.TTS_EMPTY_AUDIO,
"cloud TTS returned empty audio",
@@ -206,4 +211,14 @@ class CloudTtsProvider:
"newapi-tts",
"tts",
)
return AudioSegment(data, 16000, 1, 0, max(120, len(clean) * 45), {"text": clean, "format": "mp3"})
try:
data = base64.b64decode(encoded)
except (TypeError, ValueError) as exc:
raise ProviderError(
ErrorCode.TTS_SYNTHESIS_FAILED,
f"cloud TTS returned invalid base64 audio: {exc}",
True,
"newapi-tts",
"tts",
) from exc
return AudioSegment(data, 24000, 1, 0, max(120, len(clean) * 45), {"text": clean, "format": "wav"})